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

Допустим, вы сделали агента, который умеет сохранять города в избранное. Дальше всё просто: перед записью агент должен спросить пользователя «точно сохранить?». На бэкенде это пять строк. А вот на фронте начинается самодеятельность: агент остановился, и как об этом узнает браузер? Как нарисовать кнопки? Куда вернётся ответ? Что будет, если человек закроет вкладку и вернётся через час?

Каждая команда решает это по-своему: кастомное событие в 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, в котором агент сходил в инструмент и потом ответил текстом (поток сокращён, значения условные):

Один 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)

Вернёмся к кнопке «сохранить?». В 1.0 она описана в протоколе так.

Когда агенту нужен человек, run заканчивается. Последнее событие — RUN_FINISHED, и в нём outcome равен interrupt. Соединение закрывается. Человек думает сколько хочет, нажимает кнопку, и клиент отправляет новый запрос на тот же threadId, а в поле resume кладёт ответ.

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

Никакое соединение на время паузы держать не нужно: человек может подумать хоть час. Если 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-samples: браузер, Next.js и FastAPI с AG2
Архитектура примера ag2-samples: браузер, Next.js и FastAPI с AG2

Бэкенд

Бэкенд собирается в три шага: обычный агент 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 мне нравится, что вещи, которые каждый делал по‑своему (подтверждение человеком, субагенты, отмена, расход токенов), стали частью протокола. Для интерфейса это значит меньше самописного клея, для агента — одно место, куда всё выводить.

Ссылки:

Мой GitHub: github.com/vvlrff

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


  1. feyd12
    19.10.2026 21:13

    Я бы почитал, но пока не могу))