23. Одобрение действий (human-in-the-loop approval)
🔵 Механизм, который останавливает агента и ждёт решения человека перед выполнением чувствительного действия — платёж, отправка, изменение в CRM, удаление. Зачем это нужно и почему нельзя доверять «агент сам попросит».
23.1 Зачем
Агент становится ценнее, чем более чувствительные данные он трогает и чем более серьёзные действия выполняет. Но ровно эти действия опасны: через prompt injection в данные (письмо, страница сайта, документ) можно заставить модель сделать вредное — вернуть деньги, удалить письма, разослать сообщение.
Если одобрение оставить на усмотрение модели («тул, который агент сам вызывает, когда решит»), то атака просто «забудет» его вызвать. Поэтому в Agent OS одобрение — это принудительный гейт в ядре, а не просьба. Принцип:
Кред авторизует инструмент, одобрение авторизует действие. Вместе, а не порознь.
23.2 Два слоя
| Слой | Кто инициирует | Защищает | Обойти |
|---|---|---|---|
| Kernel-гейт | ядро, перед выполнением тула | чувствительные тулы всегда | нельзя — принудительно |
human_approval (тул) |
агент, по своей инициативе | произвольные развилки | можно (это не граница безопасности) |
Первый слой — граница безопасности. Второй — эргономика («спросить человека, как поступить»), переиспользует тот же механизм.
23.3 Модель
Запрос и решение
// событие, которое ядро рассылает каналу
ProcessEvent::ApprovalRequest {
pid, session_id, user_id,
request_id: String,
tool: String, // какой тул гейтится
action: String, // что увидит человек: «Списать 100 ₽ по счёту №7»
details: Value, // аргументы тула (или редakтированная выжимка)
severity: String, // low | medium | high
approver: String, // any | end_user | operator | admin
timestamp,
}
// ответ человека
pub struct ApprovalDecision {
pub approved: bool,
pub comment: Option<String>,
pub approver: Option<String>, // кто нажал — для аудита/подотчётности
}
Политика
# в конфиге инструмента (tool.yaml)
approval:
required: true # гейтить каждый вызов
severity: high # low | medium | high
timeout_secs: 600
on_timeout: deny # deny | allow (по умолчанию deny)
approver: any # end_user | operator | admin | any (маршрутизация)
quorum: any # any | all | <число> — сколько апруверов должно одобрить
role: finance # логическая роль (динамическая, см. §23.10)
# approvers: ["ym:a", "tg:123"] # ИЛИ физические апруверы явным списком
context: # что показать одобряющему, чтобы он понял «почему»
user_request: true # последний запрос пользователя (по умолчанию true)
recent_turns: 3 # сколько последних ходов диалога (0 = не показывать)
summary: false # вместо сырых ходов — короткое LLM-резюме диалога
# глобально, в server.yaml — не правя каждый тул
approval:
required_tools: ["send_email", "refund", "delete_*"] # glob-паттерны по имени тула
default_timeout_secs: 600
# необязательно: кастомный MiniJinja-шаблон рендера контекста одобрения
context_template: |
📝 Запрос: {{ user_request }}
{% for m in messages %}{{ "👤" if m.role == "user" else "🤖" }} {{ m.content }}
{% endfor %}
Политика для вызова разрешается так: тул → сервер. Если у тула
approval.required: true — используется его политика; иначе если имя тула
попало в server.approval.required_tools — синтезируется политика с дефолтами.
23.4 Жизненный цикл
LLM запросил tool-call
│
▼
execute_tool: проверка доступа
│
├── политика не требует одобрения ──▶ выполнить тул
│
▼
request_approval: эмитим ApprovalRequest, блокируемся (oneshot)
│
├── канал рендерит карточку (кнопки «Одобрить / Отклонить»)
│
▼
resolve_approval(request_id, decision)
│
├── approved ──▶ выполнить тул, результат в LLM-цикл
├── denied ──▶ ToolResult{success:false}, агент видит «отклонено: причина»
└── timeout ──▶ по on_timeout (deny по умолчанию)
Одобрение блокирует ход (как request_attachment), но не морит другие
сессии: семафор конкурентности держится только вокруг provider.stream, поэтому
ожидание одобрения не занимает слот инференса.
23.5 Каналы
Для события ApprovalRequest интерактивные каналы рендерят карточку с двумя
кнопками, а не текст:
- Виджет — кнопки «Одобрить / Отклонить» + опциональное поле комментария;
- Telegram — inline-клавиатура с двумя кнопками, callback вызывает
resolve_approval; - VK / Яндекс Мессенджер — текстовый ответ «да» / «нет» (кнопок нет, но ответ распознаётся и резолвит одобрение).
Правило эргономики: одобрение приходит туда, где уже сидит человек, с контекстом (action + details), в один тап, только в чувствительный момент.
Маршрутизация на оператора
Поле approver политики решает, кому показать одобрение:
any/end_user— кнопки рендерятся в том же чате (конечный пользователь решает сам);operator/admin— кнопки не показываются в чате; вместо них пользователю уходит «⏳ запрос передан оператору», а само одобрение попадает в очередь оператора (см. §23.6).
Это разрывает связку «одобрение даёт тот, с кем агент разговаривает»: платёж или списание одобряет ответственный сотрудник, а не сам клиент.
Неинтерактивные каналы (cron, email, webhook): ответить некому, поэтому
срабатывает on_timeout — по умолчанию deny. Для автономных агентов, которым
нужны предуполномоченные действия, используй on_timeout: allow явно и осознанно
(или вынеси чувствительные тулы в отдельный шаблон без гейта).
23.6 Очередь одобрений оператора (inbox)
Операторские одобрения видны и решаются в админ-UI (страница /approvals) и
через HTTP API:
| Endpoint | Описание |
|---|---|
GET /v1/approvals 🔒 |
список ожидающих одобрений (очередь) |
POST /v1/approvals/:request_id 🔒 |
решить: { "approved": true, "comment": "…" } |
Очередь — это Kernel::list_pending_approvals() (реестр pending_approval_info,
пополняется при request_approval, очищается при решении/таймауте). Решение
resolve_approval будит заблокированный ход, а в аудит пишется имя оператора
(из RBAC-принципала) — то самое «человеческое имя» на действии.
Scoped-оператор (templates: [...]) видит и решает только одобрения своих
шаблонов; полный админ — все. Неизвестный/истёкший request_id → 404.
Уведомления операторам в мессенджеры
Кроме веб-очереди, запросы можно доставлять операторам прямо в мессенджер /
webhook — тогда одобрять можно нажатием кнопки, не открывая /approvals:
# server.yaml
approval:
required_tools: ["refund", "delete_*"]
operator_notify:
- type: telegram
chat_id: -100123456789 # чат операторов
token_env: TG_OPERATOR_TOKEN
- type: webhook
url: https://ops.example.com/approvals
token_env: OPS_WEBHOOK_TOKEN # необязательно
- telegram — бот шлёт карточку с inline-кнопками «Одобрить / Отклонить»;
отдельный long-poll цикл резолвит нажатия через
resolve_approval(имя оператора = Telegram id — попадает в аудит). - webhook —
POSTJSON запроса одобрения (все поляApprovalRequest) с опциональным bearer-токеном.
Секреты — только через token_env (из .env), как везде в платформе.
Контекст запроса
Одобряющему мало «тул + аргументы» — нужно понять, почему агент делает это.
Поэтому к запросу (ApprovalRequest.context / поле context в очереди)
прикладывается контекст диалога:
{
"user_request": "Верни деньги за заказ №7",
"summary": "Клиент просит вернуть деньги за заказ, агент оформляет возврат",
"messages": [
{ "role": "user", "content": "Верни деньги за заказ №7" },
{ "role": "assistant", "content": "Понял, оформляю возврат" }
]
}
user_request— последнее сообщение пользователя;messages— последниеrecent_turnsходов (user + assistant);summary— короткое LLM-резюме диалога (когдаcontext.summary: true; тогда заменяетmessages).
Управляется approval.context.{user_request, recent_turns, summary} в политике
тула (см. §23.3). Контекст рендерится везде: в карточках виджета/Telegram, в
текстовом ответе VK/Яндекс, в очереди /approvals и в operator_notify.
Рендер настраивается глобальным MiniJinja-шаблоном approval.context_template
(переменные: user_request, summary, messages). Пусто = дефолтный рендер
(суммари с префиксом 📝, либо Запрос: … + реплики с иконками 👤/🤖, до
~200 символов на реплику). При ошибке шаблона — откат на дефолт.
23.7 Тул human_approval
Агент-инициируемая просьба о решении (слой 2):
kind: human_approval
name: ask_before_send
description: "Спросить человека, отправлять ли рассылку."
prompt: "Отправить клиенту?"
timeout_secs: 600
Параметры вызова: action (что показать), details (JSON-контекст),
timeout_secs. Возвращает в LLM-цикл { approved, comment }.
23.8 Аудит
Каждое решение фиксируется:
ProcessEvent::ApprovalResolved { request_id, tool, approved, comment, approver, timestamp }→logs/events.db;- строка
kind: "approval"в message store.
Это даёт «человеческое имя» на каждом чувствительном действии: кто, когда, что и каким решением одобрил. Без этого одобрение — просто пауза.
23.9 Ограничения (v1)
- Ожидающее одобрение хранится в памяти (
DashMap+ oneshot) — рестарт сервера теряет висящее одобрение. Согласуется с тем, что весь ход эфемерен; восстановление после рестарта — будущая работа (черезstate_store/checkpoint). - Порог «диал безопасности» (автоодобрение low-риска по severity) — будущая
работа; в v1 политика бинарна (
required: true/false).
23.10 Минимальный путь внедрения
- Ядро:
ApprovalRequest/ApprovalResolved+ApprovalDecision+request_approval/resolve_approval(клон механизма input-request). - Поле
approvalуTool+ глобальныйapproval.required_tools+ гейт вKernel::execute_tool. - Тул
human_approval+ регистрация в registry. - Кнопки в виджете + Telegram.
- Аудит
ApprovalResolved. - Очередь оператора: реестр
pending_approval_info+GET/POST /v1/approvals- страница
/approvals+ маршрутизация поapprover.
- страница
23.11 Логические апруверы (роли), кворум и физические апруверы
Кто именно одобряет — можно задать тремя способами (приоритет сверху вниз):
approvers: [...]— физические апруверы явным списком канальных id (ym:<login>,tg:<id>,vk:<id>).role: finance— логическая роль (динамическая).approver: any | end_user | operator | admin— маршрутизация.
Кворум (quorum) — сколько различных апруверов должно одобрить, прежде
чем действие выполнится. Любое «нет» отклоняет немедленно:
any— достаточно одного (по умолчанию);3(число) — минимум N различных апруверов;all— все участники роли/списка.
Роли (логические апруверы)
Роль — именованный набор участников + кворум, хранится в DuckDB
(logs/approval_roles.db) и управляется через API без передеплоя:
| Endpoint | Описание |
|---|---|
GET /v1/approvals/roles 🔒 |
список ролей |
POST /v1/approvals/roles 🔒 |
создать/обновить роль {name, quorum, members, enabled} |
DELETE /v1/approvals/roles/:name 🔒 |
удалить роль |
POST /v1/approvals/roles/:name/members 🔒 |
добавить участника {member} |
DELETE /v1/approvals/roles/:name/members/:member 🔒 |
удалить участника |
Наняли сотрудника → POST .../members {"member":"ym:new_login"} — агент и YAML
не трогаем. Уволили → DELETE. При вызове гейт резолвит роль в актуальный список
участников и кворум; роль не найдена/выключена → fail-closed (таймаут → deny).
Участники доставляются по префиксу канала через operator_notify (ym: → Яндекс
Мессенджер, tg: → Telegram с кнопками, vk: → VK); каждый участник отвечает
в своём мессенджере, ядро собирает решения до кворума.