🤖 AI-агенты для бизнеса

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 интерактивные каналы рендерят карточку с двумя кнопками, а не текст:

Правило эргономики: одобрение приходит туда, где уже сидит человек, с контекстом (action + details), в один тап, только в чувствительный момент.

Маршрутизация на оператора

Поле approver политики решает, кому показать одобрение:

Это разрывает связку «одобрение даёт тот, с кем агент разговаривает»: платёж или списание одобряет ответственный сотрудник, а не сам клиент.

Неинтерактивные каналы (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   # необязательно

Секреты — только через token_env (из .env), как везде в платформе.

Контекст запроса

Одобряющему мало «тул + аргументы» — нужно понять, почему агент делает это. Поэтому к запросу (ApprovalRequest.context / поле context в очереди) прикладывается контекст диалога:

{
  "user_request": "Верни деньги за заказ №7",
  "summary": "Клиент просит вернуть деньги за заказ, агент оформляет возврат",
  "messages": [
    { "role": "user", "content": "Верни деньги за заказ №7" },
    { "role": "assistant", "content": "Понял, оформляю возврат" }
  ]
}

Управляется 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 Аудит

Каждое решение фиксируется:

Это даёт «человеческое имя» на каждом чувствительном действии: кто, когда, что и каким решением одобрил. Без этого одобрение — просто пауза.

23.9 Ограничения (v1)

23.10 Минимальный путь внедрения

  1. Ядро: ApprovalRequest/ApprovalResolved + ApprovalDecision + request_approval/resolve_approval (клон механизма input-request).
  2. Поле approval у Tool + глобальный approval.required_tools + гейт в Kernel::execute_tool.
  3. Тул human_approval + регистрация в registry.
  4. Кнопки в виджете + Telegram.
  5. Аудит ApprovalResolved.
  6. Очередь оператора: реестр pending_approval_info + GET/POST /v1/approvals
    • страница /approvals + маршрутизация по approver.

23.11 Логические апруверы (роли), кворум и физические апруверы

Кто именно одобряет — можно задать тремя способами (приоритет сверху вниз):

  1. approvers: [...] — физические апруверы явным списком канальных id (ym:<login>, tg:<id>, vk:<id>).
  2. role: finance — логическая роль (динамическая).
  3. approver: any | end_user | operator | admin — маршрутизация.

Кворум (quorum) — сколько различных апруверов должно одобрить, прежде чем действие выполнится. Любое «нет» отклоняет немедленно:

Роли (логические апруверы)

Роль — именованный набор участников + кворум, хранится в 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); каждый участник отвечает в своём мессенджере, ядро собирает решения до кворума.