操作审批

🔵 一种在敏感操作执行前暂停智能体并等待人工决策的机制——支付、发送、修改 CRM、删除。为什么需要它,以及为什么不能信任“智能体自己会请求”。


23.1 为什么需要

智能体接触的数据越敏感、执行的操作越重要,它就越有价值。但恰恰是这些操作很危险:通过数据(邮件、网页、文档)中的 prompt injection,可以迫使模型做出有害行为——退款、删除邮件、群发消息。

如果把审批交给模型自行决定(“智能体自己决定何时调用的工具”),那么攻击只需“忘记”调用它即可。因此在 Agent OS 中,审批是内核中的强制关卡,而不是请求。原则是:

凭据授权工具,审批授权操作。 两者兼备,而非各自为政。

23.2 两个层次

层次 由谁发起 保护对象 能否绕过
内核关卡 内核,在工具执行前 敏感工具始终受保护 不能——强制
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-резюме диалога
  action: "Списать {{ input.amount }} ₽ со счёта {{ input.account }}"  # текст карточки
  when: "input.amount > 10000"   # гейтить только при истинном условии

条件关卡(when)。 基于工具参数的 MiniJinja 表达式(可见范围内为 input)。仅当表达式为真时才应用关卡;否则调用无需审批直接执行。这样小操作就不会淹没操作员。接受裸形式(input.amount > 10000)和模板形式({{ input.amount }} > 10000)。无效表达式视为真(fail closed)——宁可询问,也不要静默放行。

卡片文本(action)。 基于参数的 MiniJinja 模板({{ input.amount }}),替换默认的“是否执行工具「X」的操作?”。空值/损坏值回退到默认文本。

# глобально, в 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(或将敏感工具移入不带关卡的单独模板)。

委托给子智能体。 当智能体将另一个智能体作为子智能体启动时(带父子关系的 in-process child——subagent_tool,而工程师使用静态 delegate),子进程的 approval 会透传到父级聊天:卡片出现在人所在的地方,并 inline 批准(内核也会沿父级链发送请求,relay_to_ancestor_streams)。因此,要真正对另一个智能体进行操作,请使用委托并 父级连接,而不是 HTTP 启动(agent_run → /v1/inbound):那里调用方会丢失,审批会进入超时 → deny。

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。

向操作员发送即时通讯通知

除了 Web 队列,请求还可以直接投递到操作员的即时通讯 / 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)。上下文会在各处渲染:在 widget/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. Tool 的 approval 字段 + 全局 approval.required_tools + Kernel::execute_tool 中的门控。
  3. 工具 human_approval + 在 registry 中注册。
  4. widget + 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);每个参与者在自己的即时通讯中回复,核心收集裁决直至达到法定人数。