操作审批
🔵 一种在敏感操作执行前暂停智能体并等待人工决策的机制——支付、发送、修改 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 事件,交互式渠道渲染带两个按钮的卡片,而非文本:
- Widget——「批准 / 拒绝」按钮 + 可选评论字段;
- Telegram——带两个按钮的 inline 键盘,callback 调用
resolve_approval; - VK / Yandex Messenger——文本回复「是」/「否」(没有按钮,但回复会被识别并解决审批)。
人体工学规则:审批到达人已经在的地方,带上下文(action + details),一键完成,仅在敏感时刻出现。
路由到操作员
策略的 approver 字段决定审批展示给谁:
any/end_user——按钮渲染在同一聊天中(最终用户自行决策);operator/admin——按钮不在聊天中显示;取而代之,用户收到「⏳ 请求已转交操作员」,而审批本身进入操作员队列(见 §23.6)。
这打破了“审批由与智能体对话的人给出”的绑定:支付或扣款由负责的员工批准,而非客户本人。
非交互式渠道(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 # необязательно
- telegram——机器人发送带有内联按钮“批准 / 拒绝”的卡片;单独的 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)。上下文会在各处渲染:在 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 审计
每个裁决都会被记录:
ProcessEvent::ApprovalResolved { request_id, tool, approved, comment, approver, timestamp }→logs/events.db;- message store 中的
kind: "approval"行。
这为每个敏感操作提供了“人类名称”:谁、何时、以何种裁决批准了什么。没有这一点,审批就只是一个暂停。
23.9 限制(v1)
- 待处理审批存储在内存中(
DashMap+ oneshot)——服务器重启会丢失挂起的审批。这与整个回合是临时的相一致;重启后恢复是未来工作(通过state_store/checkpoint)。 - “安全对话”阈值(按 severity 自动批准低风险)——未来工作;在 v1 中策略是二元的(
required: true/false)。
23.10 最小实施路径
- 核心:
ApprovalRequest/ApprovalResolved+ApprovalDecision+request_approval/resolve_approval(克隆 input-request 机制)。 Tool的approval字段 + 全局approval.required_tools+Kernel::execute_tool中的门控。- 工具
human_approval+ 在 registry 中注册。 - widget + 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);每个参与者在自己的即时通讯中回复,核心收集裁决直至达到法定人数。