Files
AgentLab/backend/app/engine/approval.py
T
ThearchyHelios a819f92903 审批只认明确的"同意",而且每个会执行危险操作的地方都得过这道门
三个口子,都实测复现过:

1. 回复"拒绝",工具照样执行。四个节点四套审批解析:工具节点和代码节点对
   回复一律 bool(),"拒绝"、"no" 都是真;{"decision": "approve"} 反被当成
   拒绝;{"approved": "false"} 也是批准。现在收成一份 engine/approval.py:
   只认明确的批准,看不懂的一律按拒绝。填内容的 human input/edit 模式另算——
   裸字符串是交稿,只有结构体里明说"不"才驳回。

2. 可写数据源上的 DELETE 从来不问人。数据源工具是动态生成的,查不到
   ToolSpec,任何审批模式下都被当成安全;is_dangerous_call 写好了但全仓库
   没人调用。现在危险与否逐次判定:registry.call_is_dangerous 统一问,数据源
   工具把判定挂在 metadata["dangerous_if"] 上——同一个 db_query,查询直接
   跑,可写源上的写要先确认。工具节点、agent 节点、协作成员都走它。

   这一步以前没出事,只因为写入从不提交:run_query 走 stream,拿不到结果集就
   抛 ResourceClosedError,连接关闭时再回滚——审批通过的写也没落过库。审批接
   好之后才让写真正提交(先后不能反,反过来就是一个无审批写库的窗口);
   RETURNING 这类带结果集的写照样返回行。工具页的"试一下"是人自己在执行,
   不设审批。

3. 协作团队的成员调工具完全不经审批。成员们并行跑在一个节点里、停不下来等
   人(恢复时节点整个重放,几个人的 interrupt 对不上号),所以需要确认的调用
   不执行,把原因交还给成员让它换做法;节点上显式设成"全部自动放行"才跑。
   前端补了这个选项。顺带修了同一处的两个老问题:成员步数不受 max_agent_steps
   约束;步数用完时把工具的原始返回当结论交上去(agent 节点在 4057cdc 修过
   同一个问题,这里没跟上)——现在补一轮不带工具的收尾。

连带修的:多条审批并存时,交给引擎的是库里存的那份回复,而它存之前被 _safe
截到了 2000 字,解包时又只留 value——人工改过的长稿被砍一截,带了内容的驳回
被当成交稿。现在存全文、只拆自己包的那层 {"value": x}。

test_approval.py 36 例;去掉修复重跑,引擎层 10 例全部失败,各对应一个真问题。
并行驳回那例翻出了一个运行卡死的老 bug,已在上一个提交单独修掉。
338 → 374 passed,连跑三次全绿。
2026-09-25 15:21:20 +08:00

82 lines
3.0 KiB
Python

"""人工审批的回复怎么读。全引擎只此一份。
以前有四份:human 节点、agent 节点、工具节点、代码节点各读各的,口径不一。
后果不是风格问题:工具节点和代码节点对字符串一律 bool(),回复"拒绝"、"no"
都被当成批准,工具真的执行了;而 {"decision": "approve"} 反被判成拒绝。
规则只有一条:**只认明确的批准,看不懂的一律按拒绝处理。** 审批这道门放错了
的代价(危险工具被执行)远大于拦错了的代价(再批一次)。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
_YES = frozenset({
"approve", "approved", "yes", "y", "true", "ok", "allow",
"同意", "通过", "批准", "允许", "可以",
})
_NO = frozenset({
"reject", "rejected", "deny", "denied", "no", "n", "false",
"拒绝", "驳回", "不同意", "不通过", "不允许", "不可以",
})
@dataclass(frozen=True)
class Decision:
#: 明确批准了。审批类的关卡(工具、代码、human 的 approve 模式)只认这个。
approved: bool
#: 明确拒绝了。填内容类的关卡(human 的 input / edit 模式)只在这时才停:
#: 那两种模式的回复本身就是内容,没说"不"就是交了稿。
rejected: bool
note: str = ""
#: 人工改过的工具参数
args: dict[str, Any] | None = None
#: 人工改过的代码
code: str | None = None
#: human 节点收到的内容(input / edit 模式)
value: Any = None
def _word(value: Any) -> str:
return value.strip().lower() if isinstance(value, str) else ""
def _verdict(value: Any) -> bool | None:
"""True / False / None(没表态)。"""
if isinstance(value, bool):
return value
word = _word(value)
if word in _YES:
return True
if word in _NO:
return False
return None
def read_decision(response: Any) -> Decision:
if isinstance(response, dict):
verdict = _verdict(response["approved"]) if "approved" in response \
else _verdict(response.get("decision"))
args = response.get("args")
code = response.get("code")
return Decision(
approved=verdict is True,
rejected=verdict is False,
note=str(response.get("note") or response.get("comment") or ""),
args=args if isinstance(args, dict) else None,
code=code if isinstance(code, str) and code else None,
value=response.get("value"),
)
verdict = _verdict(response)
# 不是一个是/否词的字符串("拒绝,这个表是生产库")既算不上批准,本身又
# 是理由——留作备注,agent 转告模型时才不会只剩一句"未说明"
note = response.strip() if isinstance(response, str) and verdict is None else ""
return Decision(
approved=verdict is True,
rejected=verdict is False,
note=note,
# 字符串回复本身就是内容:input 模式下用户可能直接回一句话
value=response,
)