Directual
Menu

ИИ-агент на Directual без самописного цикла: подключаем theloop

Агентный цикл «модель → инструменты → модель» неудобно собирать из шагов сценария: ретраи, стриминг, лимиты и подсчёт токенов быстро превращают его в кашу. Разбираем схему, в которой цикл крутит theloop — открытый stateless HTTP-сервис, — а Directual остаётся оркестратором: хранит историю, данные, права, подтверждения и биллинг.

ИИ-агент на Directual без самописного цикла: подключаем theloop

В статье про 10 мифов об ИИ-агентах мы писали, что LLM — исполнитель, и чтобы он работал надёжно, его нужно встроить в систему. А в материале «ИИ-агенты: не тренд, а необходимость» перечисляли, какая инфраструктура для этого нужна. Сегодня покажем, как это сделать на практике: агент ходит в ваши данные, предлагает действия и ждёт подтверждения пользователя. При этом сам агентный цикл в Directual мы собирать не будем.

Схема не теоретическая: в таком виде она уже работает в продакшене у одного из наших клиентов.

Что такое агентный цикл и зачем отдавать его наружу

Чат-бот отправляет модели вопрос и показывает ответ — так устроено простое подключение ChatGPT к Directual. Агент устроен сложнее: модель может попросить вызвать инструмент («найди заказ 12345»), получить результат, попросить ещё один и только потом ответить. Это и есть цикл: модель → инструменты → модель → … → финальный ответ.

Собрать такой цикл в сценарии Directual технически можно: HTTP request к провайдеру, условие «есть ли tool_calls», ещё один HTTP request, переход назад. На демо это даже работает. В продакшене вылезает всё остальное:

  • провайдер вернул 429 или оборвал соединение посередине ответа;
  • пользователь хочет видеть ответ по мере генерации, а не через 40 секунд;
  • модель зациклилась и десятый раз вызывает один и тот же инструмент;
  • нужно посчитать токены по каждой попытке, включая неудачные, чтобы выставить клиенту счёт;
  • у каждого провайдера свой формат tool calls, кэша и reasoning;
  • пользователь нажал «стоп».

Всё это работа не для оркестратора, а для движка. Мы вынесли её в отдельный open-source сервис theloop.

theloop в двух словах

theloop — агентный цикл в виде stateless HTTP-сервиса (Apache 2.0, Node 22). Один запрос POST /runs — один прогон. В запросе приходит всё: провайдер, модель, ключ, системный промпт, полная история диалога, инструменты (URL + JSON-схема) и лимиты. Движок крутит цикл до финального ответа, стримит текст по SSE, а результат и точный расход токенов отправляет в callback. Между вызовами он ничего не помнит.

Поэтому он хорошо стыкуется с Directual. Всё, что требует хранения, остаётся у вас: история, ассистенты, промпты, ключи, операции, подтверждения, деньги. В терминах theloop Directual — это harness, то есть оркестратор. Правило разделения простое:

Всё, что решается хранением данных или вызовом эндпоинта, — в Directual. Всё, что требует цикла, стрима или таймера, — в theloop.

Провайдеры из коробки: Anthropic, OpenAI, DeepSeek, GLM, GigaChat, MiniMax, Kimi и любой OpenAI-совместимый эндпоинт. Можно передать цепочку фоллбэков: если основная модель упала до начала ответа, движок перейдёт к следующей.

Схема

 Фронтенд ──пишет──► raw_chat_message
    ▲                     │
    │ сокет:              ▼
    │ stream_url    ┌──────────────── Directual ────────────────┐
    │               │ S1 «Старт прогона»                        │
    │               │   собрать историю, промпт, инструменты    │
    │               │   HTTP request: POST /runs ───────────────┼──► theloop
    │               │                                           │       │
    │               │ API endpoint'ы инструментов ◄─────────────┼───────┤ вызовы tools
    │               │   read: отдать данные                     │       │
    │               │   write: зарегистрировать операцию        │       │
    │               │                                           │       │
    │               │ API endpoint callback ◄───────────────────┼───────┘ результат
    │               │   записать сообщения, usage, статус       │
    │               └───────────────────────────────────────────┘
    │
    └──── SSE по stream_url (текст по мере генерации) ──── theloop

Directual в цикл не лезет: он запускает прогон, отвечает на вызовы инструментов как обычный API и принимает результат.

Шаг 0. Поднимаем theloop

Нужен один всегда включённый контейнер с публичным HTTPS-адресом:

docker run -p 8080:8080 \
  -e ENGINE_TOKEN=длинный-случайный-токен \
  -e CALLBACK_SECRET=другой-случайный-секрет \
  -e PUBLIC_URL=https://loop.example.com \
  -e EGRESS_ALLOWLIST=api.directual.com \
  -e ALLOWED_ORIGINS=https://app.example.com \
  -e ENGINE_LOCALE=ru \
  -e DEFAULT_TIMEZONE=Europe/Moscow \
  ghcr.io/paveler/theloop:1

Что здесь важно:

  • ENGINE_TOKEN — им Directual авторизуется в движке. Храните его в секретах приложения и никогда не отдавайте на фронтенд: браузер ходит в движок по одноразовым токенам прогона.
  • EGRESS_ALLOWLIST — движок ходит только на разрешённые хосты, только по HTTPS, без редиректов и приватных IP. Инструменты и callback живут в API Directual, поэтому разрешаем api.directual.com.
  • CALLBACK_SECRET — движок кладёт его в заголовок X-Engine-Secret каждого callback'а. По нему Directual отличает настоящий результат от подделки.
  • ENGINE_LOCALE и DEFAULT_TIMEZONE — по умолчанию движок пишет служебные строки по-английски и считает время в UTC. Для русскоязычного продукта задайте оба, иначе модель увидит дату не в том поясе, а пользователь получит английские ошибки.
  • Деплой движка — это рестарт, а прогоны живут в памяти. Катите обновления в тихие часы: GET /health показывает, сколько прогонов идёт прямо сейчас.

Ключи провайдеров можно задать в env (ANTHROPIC_API_KEY=…) или передавать в каждом запросе из Directual. Второй вариант удобен, если у клиентов свои ключи.

Шаг 1. Структуры

Справочники — то, что настраивает ассистента:

Структура Ключевые поля
assistant provider, model, api_key (секрет), system_prompt, params, limits, pricing, tools (links)
tool id (имя для модели), description, input_schema (json), endpoint, headers (секрет), effect (read / write), enabled

Это, пожалуй, главный бонус связки. Модель, промпт и набор инструментов — данные, а не код. Хотите сравнить Claude и GigaChat на том же диалоге? Поменяйте два поля в записи assistant, передеплой не нужен. Новый инструмент — новая запись в tool плюс эндпоинт.

Рабочие структуры — то, что живёт в диалоге:

Структура Ключевые поля Зачем
raw_chat_message session, text, ответные stream_url, cancel_url, error сюда пишет фронтенд; это намерение пользователя, а не история
chat_session state (idle / running / awaiting_tool), active_run, history_revision диалог и его состояние
chat_message id, session, payload (json), role, seq, created_at история в формате движка
run id = run_id, status, deadline_at, result (json), usage-поля, cost результаты прогонов и биллинг

Ключевое поле — chat_message.payload. В нём лежит сообщение в формате theloop (StoredMessage) целиком и без изменений: реплики пользователя, ответы модели, вызовы инструментов и их результаты, reasoning. При каждом прогоне эти payload'ы по порядку уходят в движок как messages. Не пытайтесь собирать историю из того, что видно в интерфейсе: без результатов инструментов и reasoning модель на следующем шаге теряет контекст, а часть провайдеров вообще откажется продолжать.

Остальные поля chat_message (role, превью текста, call_id) — денормализация для интерфейса и выборок.

Шаг 2. Сценарий «Старт прогона»

Триггер — новый объект в raw_chat_message.

1. Проверить состояние сессии. Если state = running, пользователь пишет, пока агент ещё отвечает. Делаем то же, что делает человек, которого перебили: POST /runs/{id}/cancel. Отмена синхронная, движок сразу возвращает результат с уже сгенерированным текстом. Записываем его так же, как результат в callback'е (шаг 5), отправляем POST /runs/{id}/ack и идём дальше. Два прогона на одну сессию одновременно запускать нельзя: второй получит историю без ответа первого.

2. Записать сообщение пользователя в chat_message. В качестве id берём id raw_chat_message:

{ "role": "user",
  "content": [ { "type": "text", "text": "{{text}}" } ],
  "created_at": "{{date_created}}" }

history_revision увеличиваем на 1. created_at обязателен: по нему движок показывает модели дату каждого сообщения, одну и ту же на любом ходе. Тогда история между ходами только дописывается и prompt cache провайдера продолжает работать.

3. Собрать тело запроса. Удобнее всего это сделать JS-шагом (если с JSON в сценариях вы ещё не работали, начните с урока про JSON и связи), который соберёт system, массив messages (все payload сессии, отсортированные по created_at, seq) и массив tools из записей справочника:

{
  "contract_version": "1",
  "idempotency_key": "{{raw_chat_message.id}}",
  "history_revision": {{chat_session.history_revision}},
  "llm": {
    "provider": "{{assistant.provider}}",
    "model": "{{assistant.model}}",
    "api_key": {{assistant.api_key | null}},
    "params": {{assistant.params}},
    "pricing": {{assistant.pricing | null}}
  },
  "system": "{{system}}",
  "messages": {{messages}},
  "tools": {{tools}},
  "limits": {{assistant.limits}},
  "callback": { "url": "<URL эндпоинта callback, шаг 5>" },
  "meta": {
    "session_id": "{{chat_session.id}}",
    "user_id": "{{user.id}}",
    "tenant_id": "{{tenant.id}}",
    "assistant_id": "{{assistant.id}}",
    "timezone": "Europe/Moscow"
  }
}

Пара практических мелочей. Пустые поля отправляйте как null, а не пустой строкой: "api_key": "" не пройдёт валидацию. Массивы в JS-шаге собирайте циклом с push, а не concat. Дату и время в промпт добавлять не нужно: движок допишет их сам по meta.timezone.

Системный промпт тоже можно собирать из данных. Например, ядро промпта плюс разделы, которые зависят от состояния диалога: есть ли у пользователя заполненный профиль, открыт ли чат из карточки конкретного объекта. Тем же условием режется и набор инструментов. Модель в этом выборе не участвует, всё решает обычная логика сценария.

4. HTTP request: POST {ENGINE_URL}/runs с заголовком Authorization: Bearer {ENGINE_TOKEN}.

5. Разобрать ответ:

  • 202 — прогон запущен. Создаём run с id = run_id, status = running и deadline_at = now + timeout_ms + 120 с. Ставим сессии state = running, а в raw_chat_message пишем stream_url и cancel_url. Фронтенд подхватывает их по сокету и подписывается на стрим;
  • 200 — повтор того же idempotency_key: прогон уже идёт, второй не запустился. Делаем то же самое;
  • 409 result_pending — результат предыдущего прогона ещё не записан. Забираем его через GET /runs/{id} (шаг 7), записываем, отправляем ack и повторяем запрос;
  • 429 или 503 — движок перегружен или перезапускается. Пауза пару секунд и повтор;
  • 400 — ошибка в собранном теле, в ответе написано, какая именно.

Дальше сценарий заканчивается. Ждать окончания прогона не нужно: результат придёт в callback.

Шаг 3. Read-инструменты — это ваши API endpoint'ы

Инструмент в theloop — это URL и JSON-схема аргументов. Когда модель просит инструмент, движок отправляет на этот URL POST. В Directual это обычный API endpoint с синхронным сценарием, а URL и заголовок авторизации лежат в записи tool.

Тело, которое приходит на эндпоинт:

{ "contract_version": "1", "run_id": "run_…", "call_id": "call_…", "attempt": 1,
  "origin_request_id": "…", "tool": "getOrders",
  "args": { "orderId": "12345" },
  "meta": { "session_id": "…", "user_id": "…", "tenant_id": "…" },
  "result_url": "…" }

Сценарий делает выборку и отвечает. Формат ответа задаётся в API response эндпоинта:

  • { "status": "ok", "result": { … } } — результат уходит модели;
  • { "status": "error", "error": "заказ не найден" } — модель увидит ошибку и переформулирует запрос, прогон при этом не падает.

Главное правило безопасности: фильтруйте данные по meta, а не по args. В args модель может подставить что угодно, в том числе чужой user_id, если её уговорили в промпте. meta модель не видит и подделать не может: его кладёт Directual при запуске прогона. Поэтому сценарий ищет заказы с условием user = meta.user_id, а orderId из args использует только как фильтр внутри этой выборки.

Ещё три совета:

  • Заголовки авторизации — только Authorization или X-*. Из соображений безопасности движок пропускает к инструментам только их. Заголовок с другим именем (Api-Key, Token) он молча выкинет, и эндпоинт ответит 401.
  • Возвращайте компактный результат. Пять нужных полей заказа, а не весь объект со связями. Движок обрежет ответ по tool_result_max_bytes, а каждый лишний байт вы оплачиваете токенами на каждом шаге цикла.
  • Пустой результат — это нормально. Нет заказов — верните пустой список. Движок сам объяснит модели, что данных нет и повторный вызов ничего не изменит, так что зацикливания не будет.

Шаг 4. Write-инструменты: агент предлагает, человек подтверждает

Самое интересное — инструменты, которые что-то меняют: оформить возврат, отменить заказ, списать деньги. В theloop у них "effect": "write", и они работают по особому правилу:

Write-эндпоинт ничего не меняет. Он регистрирует намерение — создаёт операцию — и всегда отвечает { "status": "deferred", "operation_id": "…" }.

В Directual это тот же API endpoint с синхронным сценарием, а нужный формат задаётся в API response: { "status": "deferred", "operation_id": "{{operation.id}}" }.

Получив deferred, движок завершает прогон со статусом awaiting_tool и забывает о нём. Дальше всё происходит в Directual:

  1. Сценарий эндпоинта createReturn создаёт объект в operation со статусом pending и отвечает deferred.
  2. Фронтенд по сокету видит новую операцию и показывает карточку: «Оформить возврат по заказу 12345 на 4 990 ₽? [Подтвердить] [Отклонить]».
  3. По нажатию сценарий выполняет действие или отказывается от него, записывает результат вызова в chat_message (id = call_id) и запускает новый прогон, уже без нового сообщения пользователя:
{ "role": "tool", "call_id": "call_…", "tool": "createReturn",
  "operation_id": "op_…", "result": { "return_id": "R-881" }, "is_error": false }

При отказе пишем "is_error": true, "error": "отклонено пользователем", и модель сама объяснит пользователю, что дальше.

Почему так, а не «пусть инструмент сразу делает возврат»? Причин три:

  • Prompt injection. Если модель обманули через письмо клиента или текст на сайте, она может попросить действие, но не может выполнить его. Выполнение всегда проходит через вашу логику и подтверждение человека.
  • Ретраи. Движок повторяет вызов инструмента при сетевой ошибке, а после сбоя прогон могут перезапустить целиком. Если бы инструмент сразу списывал деньги, вы получили бы двойное списание. Регистрация намерения безопасна при повторе.
  • Долгие операции. deferred подходит для всего, что занимает часы: согласование у менеджера, ответ банка, генерация документа. Движок не держит соединение, а Directual умеет ждать сколько угодно.

Чтобы повторный вызов не создал вторую операцию, её id должен быть детерминированным: op_ + sha256 от meta.session_id, имени инструмента и origin_request_id (это id сообщения пользователя). sha256 считается прямо в сценарии. Повторный вызов с теми же данными попадёт в тот же объект. Перед выполнением проверяйте версию операции: модель могла успеть поменять сумму следующим вызовом, и пользователь не должен подтвердить то, чего не видел. При исполнении передавайте operation.id во внешнюю систему (банк, платёжку) как её ключ идемпотентности. Полный алгоритм, включая поиск уже существующей операции, описан в спецификации, §6.4.

Если пользователь вместо нажатия на карточку пишет что-то новое, закройте висящий вызов синтетическим результатом "error": "superseded" и запустите прогон с новым сообщением. Сама операция при этом остаётся, и её карточку можно подтвердить позже.

Шаг 5. Callback: принимаем результат

Когда прогон закончился, движок отправляет POST на callback.url. Тело приходит в конверте:

{ "payload": { "run_id": "run_…", "status": "completed", "messages": [ … ],
               "usage": { … }, "tool_calls": [ … ], "meta": { … }, … } }

Конверт с одним полем сделан специально под эндпоинты Directual. Эндпоинт принимает только поля из своего списка на запись. Если бы тело было плоским, вы получили бы … is not allowed for write на первом же неизвестном поле, а массив messages в json-поле упал бы с unsupported conversion from array to json. С конвертом эндпоинту нужно одно поле на запись — payload (json), и новые поля результата его не сломают.

Синхронный сценарий эндпоинта:

  1. Проверяет подпись. Сравнивает заголовок X-Engine-Secret с константой и отклоняет запрос, если не совпало. Секрет не кладите в query URL callback'а: query оседают в логах прокси.
  2. Делает upsert run по payload.run_id. Callback доставляется at-least-once, дубли будут. Сам эндпоинт id записи не задаёт, поэтому дедупликацией занимается сценарий: ищет run по run_id и обновляет его, а не создаёт новый. В run пишет result, status, usage-поля и cost.
  3. Пишет сообщения. Для каждого элемента payload.messages — upsert chat_message с id = <run_id>:<seq> и payload как есть. Это только новые сообщения этого прогона, старые движок никогда не возвращает. Детерминированный id делает повторную доставку безопасной: дубль перезапишет те же объекты.
  4. Обновляет сессию. completed, stopped_limit, cancelled и failed переводят её в idle, awaiting_tool — в awaiting_tool со списком висящих call_id. history_revision увеличивается на 1.
  5. Отвечает 200 только после всех записей. Для движка 2xx означает «результат сохранён»: после этого он снимает блокировку сессии. Если сценарий упал посередине, отвечайте ошибкой, и движок повторит отправку (5 попыток примерно за 80 секунд). Все записи идемпотентны, так что повтор ничего не задвоит.

Перед подключением движка проверьте эндпоинт руками: отправьте curl'ом реальное тело результата дважды. После второго раза в chat_message не должно появиться дублей.

Про stopped_limit: если модель упёрлась в max_steps, движок даёт ей последний шаг без инструментов и всё равно возвращает текстовый ответ. Это не ошибка, показывайте его как обычный ответ. При failed в error.message уже лежит понятный пользователю текст на языке из ENGINE_LOCALE. Покажите его как системное сообщение, в историю его писать не нужно.

Шаг 6. Стриминг и стоп

Фронтенд по сокету видит stream_url в своём raw_chat_message и подписывается на стрим напрямую в движке. Прокси через Directual не нужен: в ссылке уже есть токен, действующий только для этого прогона.

const es = new EventSource(msg.stream_url)
es.addEventListener('delta', e => appendText(JSON.parse(e.data).text))
es.addEventListener('tool_call', () => showStatus('Смотрю данные…'))
es.addEventListener('done', () => es.close())

По умолчанию стрим отдаёт только текст и обезличенный прогресс: ни аргументов, ни результатов инструментов. В результатах могут быть данные, которые модели видеть нужно, а пользователю целиком — нет.

Кнопка «Стоп» — это POST cancel_url прямо из браузера. Directual ничего специально делать не должен: результат отменённого прогона придёт обычным callback'ом.

Тонкий момент: событие done в браузере ещё не значит, что история записана. Записанные сообщения появятся в chat_message после callback'а, а стрим — только предпросмотр.

Шаг 7. Страховка от потерянных прогонов

theloop держит прогоны в памяти. Если контейнер упадёт, прогон пропадёт и callback не придёт. Поэтому нужен сценарий по расписанию, раз в минуту:

  • найти run со status = running и deadline_at < now;
  • сделать GET /runs/{id}. Если пришёл готовый результат, записать его так же, как в шаге 5, и отправить POST /runs/{id}/ack. Если прогон ещё идёт, продлить дедлайн;
  • если пришёл 404, прогон потерян: run.status = lost, сессию в idle, пользователю сообщение «не удалось получить ответ, повторите».

lost не окончательный статус. Если поздний callback всё-таки придёт, примите его: сравните history_revision из результата с текущей. Если ревизии совпадают, записывайте как обычно. Если уже был следующий ход, сохраните результат как аудит, но в историю не вставляйте.

Что получилось

Directual theloop
интерфейс, пользователи, права агентный цикл
история диалога и её порядок стриминг и отмена
ассистенты, промпты, модели и ключи как данные ретраи провайдеров, фоллбэки, rate limiting
данные и инструменты как API endpoint'ы вызов инструментов, валидация аргументов, защита от зацикливания
операции, подтверждения, исполнение действий deferred вместо мутаций
биллинг клиентов по usage точный подсчёт токенов по каждой попытке

Каждый занимается своим. Directual не нужно учить ретраям провайдеров и SSE, а движку — знать, кто ваши клиенты и что им можно. Мультитенантность получается бесплатно: в движке нет состояния тенантов, всё приходит в запросе.

Чем theloop пока не является: это не конструктор агентов, не RAG и не мультиагентная система. Один инстанс, прогоны в памяти. Нужен поиск по базе знаний — оформите его как read-инструмент: эндпоинт в Directual, который ищет и возвращает фрагменты.

С чего начать

  1. Поднимите theloop и прогоните запрос из README с "wait": true. Тогда ответ придёт сразу в теле запроса — ровно то же тело потом будет приходить в callback.
  2. Заведите в Directual структуры из шага 1, одного ассистента и один read-инструмент. Если API endpoint'ы и HTTP request в сценариях для вас в новинку, загляните в урок по API для начинающих.
  3. Соберите старт прогона и callback, проверьте callback двойной отправкой. Затем добавьте стриминг, страховку по дедлайну и write-инструменты.

Полный контракт — в SPEC.md. Особенно полезно приложение A: чек-лист обязательств оркестратора. Большинство проблем в продакшене возникает из-за пропуска одного из этих пунктов. Пример оркестратора на SQLite лежит в examples/sqlite-harness: логика та же, только вместо структур Directual — таблицы.

Вопросы, баги и истории о том, как вы собрали агента на Directual, присылайте в issues на GitHub.

Если вы только подступаетесь к агентам, пройдите наш бесплатный курс по ИИ-агентам: за час он даёт базу, на которую потом ложится эта схема. Что ещё Directual умеет для агентных продуктов, собрано на странице ИИ-агенты на Directual.

FAQ

Что такое theloop?

theloop — открытый (Apache 2.0) агентный цикл в виде stateless HTTP-сервиса. Один запрос POST /runs — один прогон: движок вызывает модель и инструменты до финального ответа, стримит текст по SSE и отправляет результат с точным расходом токенов в callback. Между вызовами он ничего не хранит.

Зачем выносить агентный цикл из Directual, если его можно собрать в сценарии?

На демо цикл из HTTP-запросов и условий работает, но в продакшене нужны ретраи при 429 и обрывах, стриминг, защита от зацикливания, подсчёт токенов по каждой попытке, разные форматы tool calls у провайдеров и кнопка «стоп». Это работа движка, а не оркестратора. Directual при этом хранит историю, данные, права, подтверждения и биллинг.

Как агент безопасно выполняет действия вроде возврата или списания денег?

Write-инструменты ничего не меняют сами: эндпоинт в Directual регистрирует операцию и отвечает status: deferred. Пользователь видит карточку с подтверждением, и только после нажатия сценарий выполняет действие. Так prompt injection не может привести к реальному действию, а ретраи не создают двойных списаний.

Как не дать модели получить чужие данные через инструменты?

Фильтруйте выборки по полю meta, а не по args. Аргументы args формирует модель, и её можно уговорить подставить чужой user_id. Поле meta кладёт Directual при запуске прогона, модель его не видит и подделать не может.

Какие LLM-провайдеры поддерживает theloop?

Из коробки: Anthropic, OpenAI, DeepSeek, GLM, GigaChat, MiniMax, Kimi и любой OpenAI-совместимый эндпоинт. Можно передать цепочку фоллбэков: если основная модель упала до начала ответа, движок переключится на следующую.
← Назад к списку