
Допустим, вы сделали агента, который умеет сохранять города в избранное. Дальше всё просто: перед записью агент должен спросить пользователя «точно сохранить?». На бэкенде это пять строк. А вот на фронте начинается самодеятельность: агент остановился, и как об этом узнает браузер? Как нарисовать кнопки? Куда вернётся ответ? Что будет, если человек закроет вкладку и вернётся через час?
Каждая команда решает это по-своему: кастомное событие в WebSocket, поле needs_approval в JSON, отдельный эндпоинт для ответа. Работает, пока агент один и интерфейс один. Потом вы меняете фреймворк на бэкенде, и всю эту обвязку приходится писать заново.
Привет, я Семён Фролов, инженер в AG2.
15 октября Tesla показала Roadster, который умеет летать, и весь интернет разбирает эту презентацию по кадрам. А мы вместо этого рассмотрим не менее важную вещь: мажорный релиз AG‑UI.
TL;DR:
17 сентября вышли пакеты 1.0.0, 30 сентября CopilotKit объявил релиз;
из нового самое заметное: interrupts (агент ставит run на паузу и ждёт человека), субагенты, reasoning, usage и отмена run'а, мультимодальные результаты инструментов;
протокол стал строже: версионируется, а его поведение описано как нормативное (MUST/SHOULD), с JSON Schema, из которой генерируются SDK;
ниже на примере из ag2-samples смотрим, как это выглядит в живом приложении: AG2 на бэкенде, CopilotKit на фронте.
В этой статье разберём протокол, который пытается закрыть именно эту дыру, - AG-UI. 17 сентября у него вышла версия 1.0, и в ней как раз появилось то, о чём я говорил: паузы агента в ожидании человека, субагенты, отмена и расход токенов стали частью протокола, а не договорённостью внутри команды. Я покажу всё на живом примере: агент на AG2 и чат на Next.js с CopilotKit.
AG‑UI в двух словах
Это протокол между агентом и интерфейсом. Устроен он просто. Клиент шлёт один POST с историей сообщений. Сервер отвечает потоком SSE, где каждое событие — JSON с полем type. Один запрос и один поток называются run.
Вот как выглядит run, в котором агент сходил в инструмент и потом ответил текстом (поток сокращён, значения условные):

Всё, что стримится (текст, аргументы тула, рассуждения), оформлено как START → CONTENT → END с идентификатором. Плюс есть события состояния (STATE_SNAPSHOT, STATE_DELTA) и служебные. Всего в 1.0 их 31. Агенту безразлично, что за интерфейс по ту сторону, а интерфейсу — на каком фреймворке написан агент.
Что появилось в 1.0
Пауза в ожидании человека (interrupts)
Вернёмся к кнопке «сохранить?». В 1.0 она описана в протоколе так.
Когда агенту нужен человек, run заканчивается. Последнее событие — RUN_FINISHED, и в нём outcome равен interrupt. Соединение закрывается. Человек думает сколько хочет, нажимает кнопку, и клиент отправляет новый запрос на тот же threadId, а в поле resume кладёт ответ.

Никакое соединение на время паузы держать не нужно: человек может подумать хоть час. Если interrupt'ов несколько, resume обязан ответить на каждый.
Субагенты
Если ваш агент зовёт другого агента, интерфейс раньше этого не видел: снаружи просто долго идёт один run. Теперь есть события SUBAGENT_STARTED, SUBAGENT_FINISHED и SUBAGENT_ERROR, а все события субагента помечены subagentRunId. Вместо лоадера можно написать «сейчас работает планировщик поездок».
Reasoning вместо thinking
Семейство THINKING_* заменили на REASONING_*. Добавилось событие для зашифрованных рассуждений: клиент их прочитать не может, но должен вернуть модели в следующем запросе как есть. Это нужно для моделей, которые отдают рассуждения в закрытом виде.
Расход токенов и отмена
В RUN_FINISHED теперь может приходить usage за весь run, с учётом субагентов. А если пользователь нажал «стоп», run закрывается с outcome: cancelled. Раньше такой run легко было принять за успешно завершённый.
Результат инструмента — не только строка
Результат теперь может быть списком частей: текст плюс картинка по ссылке. Раньше пришлось бы кодировать всё в строку.
Строгость
События несут
protocolVersion, и стороны могут понять, с кем разговаривают;Необязательное поле либо есть, либо его нет:
nullне допускается;Свои поля на события больше не вешаются, для этого есть
metadata;Поведение (что обязано открываться, закрываться, как реагировать на неизвестное) описано как нормативное, а типы SDK генерируются из схемы.
Как это выглядит в AG2 + CopilotKit
Теперь пример. В ag2-samples лежит «Weather AG UI»: агент погоды на AG2 и чат на Next.js с CopilotKit. В нём собраны почти все новые фичи из списка выше.

Бэкенд
Бэкенд собирается в три шага: обычный агент AG2, обёртка AGUIStream вокруг него и FastAPI-роут, который отдаёт поток наружу.
Шаг 1. Агент. Это обычный Agent из AG2, про AG-UI он ничего не знает (backend/agent.py):
from ag2 import Agent from ag2.config import OpenAIResponsesConfig CONFIG = OpenAIResponsesConfig(model="gpt-5.6-sol", streaming=True) agent = Agent( "WeatherAgent", prompt=PROMPT, # системный промпт: когда какой инструмент звать config=CONFIG, # какая модель и как с ней говорить tools=[get_coords_by_city, get_current_weather_by_coords, ...], )
Шаг 2. Обёртка. AGUIStream(agent) умеет превращать то, что делает агент, в события AG-UI (backend/app.py):
stream = AGUIStream(agent)
В простом случае этого хватает: stream.build_asgi() отдаёт готовое ASGI-приложение, и AG-UI эндпоинт готов. Но у нас есть авторизация: пользователя нужно достать из заголовка Authorization и показать агенту. А build_asgi() заголовки запроса не передаёт.
Шаг 3. Роутер (backend/routers/weather.py). Здесь два эндпоинта: GET отдаёт описание возможностей агента, POST запускает run.
@router.get("/weather") async def capabilities() -> JSONResponse: return JSONResponse(stream.capabilities().model_dump(by_alias=True, exclude_none=True)) @router.post("/weather") async def run( run_input: RunAgentInput, user: Annotated[User, Depends(authorised_user)], accept: Annotated[str | None, Header()] = None, ) -> StreamingResponse: events = stream.dispatch( run_input, dependencies={"user": user, "http": http}, config=config, accept=accept ) return StreamingResponse(events)
stream.dispatch запускает агента на входе из запроса и возвращает поток уже готовых событий AG-UI, который роут отдаёт как SSE. Всё, что лежит в dependencies, агент может достать в своих инструментах. Вот как инструмент get_current_user берёт оттуда пользователя (backend/tools.py):
@tool(description="Say who the user is that you are talking to: their id and display name.") async def get_current_user(user: Annotated[User, Inject()]) -> User: return user
Inject() означает «возьми из dependencies по имени параметра». Пользователь получается именно этим путём, а не через state: state приходит от клиента, и клиент может его подделать, а dependencies заполняет только наш роут.
Субагенты: просто ещё два инструмента
Субагент — это обычный Agent со своим промптом (и, если нужно, своими инструментами):
clothing_advisor = Agent( "ClothingAdvisor", prompt=( "You advise what to wear for given weather. Reply with two or three short, " "concrete suggestions (layers, footwear, accessories) based only on the weather you are given." ), config=CONFIG, ) trip_planner = Agent( "TripPlanner", prompt=( "You compare the weather of several cities and recommend where to go. Look up " "each city with your tools, then answer in one short paragraph naming the best pick and why." ), config=CONFIG, tools=[get_coords_by_city, get_weather_next_week], )
Чтобы главный агент мог их позвать, их добавляют в его tools через .as_tool():
agent = Agent( "WeatherAgent", prompt=PROMPT, config=CONFIG, tools=[ get_coords_by_city, get_current_weather_by_coords, get_weather_next_week, save_favorite_city, clothing_advisor.as_tool(description="Suggest what to wear for the given weather. ..."), trip_planner.as_tool(description="Compare the weather of several cities and recommend where to go."), ], )
Для главного агента это такие же инструменты, как остальные. Никакой отдельной настройки для событий не нужно: SUBAGENT_* в поток попадают сами. На фронте на них подписана боковая панель:
const { agent } = useAgent({ agentId: "agenticChatAgent" }); agent.subscribe({ onSubagentStartedEvent: ({ event }) => setStarted((all) => [...all, event]), onSubagentFinishedEvent: ({ event }) => setFinished((all) => ({ ...all, [event.subagentRunId]: event })), onSubagentErrorEvent: ({ event }) => setFailed((all) => ({ ...all, [event.subagentRunId]: event })), });
Чтобы увидеть это в деле, спросите «What should I wear in Oslo?» или «Compare London, Rome and Oslo».
Подтверждение в одну строку на бэкенде и один хук на фронте
Вернёмся к вопросу из начала статьи. На бэкенде инструмент помечается middleware:
@tool( description="Save a city to the user's favorites. The user is asked to confirm before anything is saved.", middleware=[ApprovalRequired("Save this city to your favorites?\n{tool_arguments}", allow_always=False)], ) async def save_favorite_city(context: Context, city: ..., country: ... = "") -> list[str]: ...
Пока человек не ответил, тело функции не выполняется: run завершается с outcome: interrupt. На фронте кнопки рисует один хук:
useInterrupt({ agentId: "agenticChatAgent", render: ({ interrupt, resolve }) => ( <div> <p>{interrupt?.message ?? "The assistant needs your confirmation."}</p> <button onClick={() => resolve(true)}>Approve</button> <button onClick={() => resolve(false)}>Reject</button> </div> ), });
resolve(true) отправляет новый запрос с resume, и агент доходит до записи. Проверить можно фразой «Save Lisbon to my favorites».
Остальное на фронте
Generative UI.
useRenderToolпревращает результат инструмента в React-компонент, например в карточку погоды. Имя должно совпадать с именем инструмента на бэкенде.Shared state. Изменённые
context.variables(избранные города, найденная локация) уезжают клиенту вSTATE_SNAPSHOT, и панель читает их изagent.state.Инструмент, который работает в браузере. На запрос «погода здесь» агент вызывает
getUserLocation, а тот черезuseFrontendToolспрашивает геолокацию у браузера. Если пользователь отказал, это возвращается агенту как обычные данные, а не как исключение: иначе упал бы весь run.
Итог
В 1.0 мне нравится, что вещи, которые каждый делал по‑своему (подтверждение человеком, субагенты, отмена, расход токенов), стали частью протокола. Для интерфейса это значит меньше самописного клея, для агента — одно место, куда всё выводить.
Ссылки:
Репозиторий примера ag2-samples https://github.com/ag2ai/ag2-samples
Спецификация AG‑UI 1.0 https://docs.ag‑ui.com/spec/1.0
AG2: AG‑UI https://docs.ag2.ai/docs/user‑guide/ag‑ui/overview/
CopilotKit: AG2 https://docs.copilotkit.ai/ag2
Разбор миграции 0.x → 1.0 https://nerdleveltech.com/ag‑ui-1-0-migration‑tested
Мой GitHub: github.com/vvlrff
feyd12
Я бы почитал, но пока не могу))