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

—

от автора

AG-UI 1.0: что нового в протоколе

AG-UI 1.0: что нового в протоколе

Привет! Меня зовут Семён Фролов, я инженер в 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, значения условные):

Один run - один поток SSE: события run, tool call и message

Один run — один поток SSE: события run, tool call и message

Всё, что стримится (текст, аргументы тула, рассуждения), оформлено как START → CONTENT → END с идентификатором. Плюс есть события состояния (STATE_SNAPSHOT, STATE_DELTA) и служебные. Всего в 1.0 их 31. Агенту безразлично, что за интерфейс по ту сторону, а интерфейсу — на каком фреймворке написан агент.


Что нового в 1.0

Interrupts: агент ждёт человека

Раньше «агент просит подтверждения» каждый делал по-своему. Теперь это часть протокола: run заканчивается событием RUN_FINISHED, в котором outcome равен interrupt. Продолжается всё новым запросом на том же threadId, с ответом человека в поле resume.

Interrupt: пауза - это конец run'а, продолжение - новый run

Interrupt: пауза — это конец run’а, продолжение — новый run

Никакое соединение на время паузы держать не нужно: человек может подумать хоть час. Если 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. В нём собраны почти все новые фичи

Архитектура примера ag2-samples: браузер, Next.js и FastAPI с AG2

Архитектура примера ag2-samples: браузер, Next.js и FastAPI с AG2

Бэкенд: 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

Мой GitHub: github.com/vvlrff

ссылка на оригинал статьи https://habr.com/ru/articles/1095968/