AG-UI 1.0: у протокола наконец появилась спецификация. Разбираем, что изменилось, на связке AG2 + CopilotKit


Привет! Меня зовут Семён Фролов, я инженер в 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 в двух словах
Клиент шлёт один POST, сервер отвечает потоком SSE. Каждое событие в потоке - JSON с полем type. Вот так выглядит ответ, в котором агент вызвал инструмент и потом написал текст (JSON собран по схеме 1.0, значения условные):

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

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

Бэкенд: AGUIStream вокруг агента
Агент оборачивается в AGUIStream, а дальше у вас два эндпоинта: GET отдаёт описание возможностей агента (capabilities), 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)dispatch сам переводит внутренние события агента в события AG-UI. Роут написан руками, потому что стандартный stream.build_asgi() не отдаёт заголовки запроса, а здесь из Authorization нужно достать пользователя и передать его в зависимости агента. Пользователь едет именно в dependencies, а не в state, потому что state клиент может перезаписать.
Субагенты: два агента как инструменты
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."),
],
)Субагент подключается через .as_tool(), а фронт получает 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 })),
});Interrupt: подтверждение в одну строчку
На бэкенде инструмент помечается 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]:
...На фронте один хук рисует кнопки:
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>
),
});Нажатие на «Approve» отправляет новый запрос с resume, и агент выполняет инструмент. Попробуйте в примере «Save Lisbon to my favorites».
Остальное на фронте
Generative UI.
useRenderToolпревращает результат инструмента в React-компонент, например, карточку погоды вместо текста. Имя должно совпадать с именем инструмента на бэкенде.Shared state. Изменённые
context.variables(избранные города, найденная локация) уезжают клиенту вSTATE_SNAPSHOT, и панель читает их изagent.state.Инструмент в браузере. На «погода здесь» агент вызывает
getUserLocation, который черезuseFrontendToolберёт геолокацию браузера. Отказ пользователя возвращается как данные, а не исключение.
Итог
В 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
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.