Files
chenxin dfce60f7db ✨ feat: WP-06 工具域——注册表、四维裁决、审批与 RunBudget
- 迁移 10:tool(注册表:风险级/审批声明/输入 Schema)、tool_call(六态含 pending_approval、幂等唯一约束)、run.max_tool_calls(RunBudget 硬上限)
- 内置工具三件:get-current-time/calculate(AST 白名单四则,只读循律)、record-note(真实副作用:写入记忆候选,必须请命)
- 四维裁决(能力/数据/后果/情境→autonomy_level):read_only 循律直接执行;side_effect/high_impact 进入 pending_approval 请命;禁用工具 forbidden
- 审批流(朱批):approve → 执行并落审计;deny → 拒绝且不可逆(重复审批 409);副作用工具产物走既有记忆审核通道
- 工具入参按不可信模型输出处理:JSON Schema 子集校验(required/type/未知字段拒绝);RunBudget 超限 422 不静默放行
- 幂等:同 (run, tool, Idempotency-Key) 重放返回 200 原调用
- 测试 67 项全绿(+7):循律即执行、请命→朱批→真实候选产物、拒绝不可逆与不执行、Schema 三类拒绝、未注册 404、幂等重放、预算硬上限
2026-09-11 11:29:43 +08:00

293 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""工具域:注册表、内置工具、Schema 校验与四维策略裁决(WP-06)。"""
import ast
import operator
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditEvent, Run, Tool, ToolCall
# ===== 内置工具实现(纯函数;副作用工具由服务层落库) =====
_UNARY: dict[type[ast.AST], Callable[[float], float]] = {
ast.USub: operator.neg, ast.UAdd: operator.pos,
}
_BINARY: dict[type[ast.AST], Callable[[float, float], float]] = {
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Mod: operator.mod, ast.Pow: operator.pow,
}
def _safe_eval(node: ast.AST) -> float:
"""AST 白名单四则运算:仅数字、一元/二元算子;任何其它节点抛 ValueError。"""
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.UnaryOp):
fn = _UNARY.get(type(node.op))
if fn is not None:
return fn(_safe_eval(node.operand))
if isinstance(node, ast.BinOp):
fn2 = _BINARY.get(type(node.op))
if fn2 is not None:
return fn2(_safe_eval(node.left), _safe_eval(node.right))
raise ValueError("表达式包含不允许的节点")
def tool_get_current_time(_input: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
return {"utc_now": now.isoformat(), "epoch_ms": int(now.timestamp() * 1000)}
def tool_calculate(input_: dict[str, Any]) -> dict[str, Any]:
tree = ast.parse(str(input_.get("expression", "")), mode="eval")
return {"result": _safe_eval(tree.body)}
BUILTIN_TOOLS: dict[str, dict[str, Any]] = {
"get-current-time": {
"description": "读取当前 UTC 时间(只读,无副作用)",
"input_schema": {"type": "object", "properties": {}, "required": []},
"risk_level": "read_only",
"requires_approval": False,
"handler": tool_get_current_time,
},
"calculate": {
"description": "四则运算(AST 白名单求值,只读)",
"input_schema": {
"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"],
},
"risk_level": "read_only",
"requires_approval": False,
"handler": tool_calculate,
},
"record-note": {
"description": "把一条笔记写入记忆候选(副作用,需审批)",
"input_schema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
"risk_level": "side_effect",
"requires_approval": True,
"handler": None,
},
}
# ===== Schema 校验(JSON Schema 子集:type/required) =====
_TYPES: dict[str, tuple[type, ...]] = {
"string": (str,), "number": (int, float), "boolean": (bool,), "object": (dict,),
}
def validate_input(schema: dict[str, Any], input_: dict[str, Any]) -> None:
props: dict[str, Any] = schema.get("properties", {})
for name in schema.get("required", []):
if name not in input_:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, f"缺少必需字段:{name}"
)
for name, value in input_.items():
spec = props.get(name)
if spec is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, f"未知字段:{name}")
expected = spec.get("type")
if expected in _TYPES and not isinstance(value, _TYPES[expected]):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, f"字段 {name} 类型应为 {expected}"
)
# ===== 四维裁决(能力/数据/后果/情境 → autonomy_level) =====
def decide(tool: Tool) -> str:
"""返回 autonomy_level:rule_bound(循律直接执行)或 approval_required(请命)。
四维映射(agent-runtime 第 6 节):能力=risk_level 与审批声明;数据=内置工具
不涉外部数据;后果=side_effect/high_impact 必须请命;情境=工具启用状态。
"""
if not tool.enabled:
return "forbidden"
if tool.requires_approval or tool.risk_level in {"side_effect", "high_impact"}:
return "approval_required"
return "rule_bound"
async def seed_builtin_tools(db: AsyncSession) -> None:
"""幂等登记内置工具(已存在则更新声明,不动 enabled)。"""
for name, spec in BUILTIN_TOOLS.items():
existing = await db.get(Tool, name)
if existing is None:
db.add(
Tool(
name=name,
description=spec["description"],
input_schema=spec["input_schema"],
risk_level=spec["risk_level"],
requires_approval=spec["requires_approval"],
)
)
else:
existing.description = spec["description"]
existing.input_schema = spec["input_schema"]
existing.risk_level = spec["risk_level"]
existing.requires_approval = spec["requires_approval"]
await db.flush()
async def create_tool_call(
db: AsyncSession,
domain_id: str,
run_id: str,
body: dict[str, Any],
trace_id: str | None,
) -> tuple[ToolCall, bool]:
"""创建工具调用:幂等重放 → Schema 校验 → RunBudget → 四维裁决。"""
run = await db.get(Run, run_id)
if run is None or run.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
key = body.get("idempotency_key")
if key:
existing = await db.execute(
select(ToolCall).where(
ToolCall.run_id == run_id,
ToolCall.tool_name == body.get("tool_name"),
ToolCall.idempotency_key == key,
)
)
replay = existing.scalar_one_or_none()
if replay is not None:
return replay, True
tool = await db.get(Tool, body.get("tool_name", ""))
if tool is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "工具未注册")
used = len(
list(
(
await db.execute(
select(ToolCall.id).where(
ToolCall.run_id == run_id,
ToolCall.status.in_(
["succeeded", "executing", "approved", "pending_approval"]
),
)
)
).scalars().all()
)
)
if used >= run.max_tool_calls:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"RunBudget 工具调用上限已达({run.max_tool_calls})",
)
input_ = body.get("input", {})
if not isinstance(input_, dict):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "input 必须是对象")
validate_input(tool.input_schema, input_) # 工具入参是不可信模型输出
level = decide(tool)
if level == "forbidden":
raise HTTPException(status.HTTP_403_FORBIDDEN, "工具已被禁用")
call = ToolCall(
run_id=run_id,
tool_name=tool.name,
input=input_,
idempotency_key=key,
status="pending_approval" if level == "approval_required" else "approved",
)
db.add(call)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="tool.call.created",
aggregate_type="tool_call", aggregate_id=call.id,
payload={"tool": tool.name, "autonomy": level}, trace_id=trace_id,
)
)
if level == "rule_bound":
await _execute(db, domain_id, call, trace_id)
await db.flush()
return call, False
async def decide_approval(
db: AsyncSession,
domain_id: str,
tool_call_id: str,
approve: bool,
trace_id: str | None,
) -> ToolCall:
"""朱批:approve → 执行;deny → 拒绝不执行且不可逆(拒绝/超时均不执行)。"""
call = await db.get(ToolCall, tool_call_id)
if call is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "调用不存在")
run = await db.get(Run, call.run_id)
assert run is not None
if run.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "调用不存在")
if call.status != "pending_approval":
raise HTTPException(status.HTTP_409_CONFLICT, "调用不在待审批状态")
if not approve:
call.status = "denied"
call.completed_at = datetime.now(UTC)
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="tool.call.denied",
aggregate_type="tool_call", aggregate_id=call.id, trace_id=trace_id,
)
)
await db.flush()
return call
call.status = "approved"
await _execute(db, domain_id, call, trace_id)
await db.flush()
return call
async def _execute(
db: AsyncSession, domain_id: str, call: ToolCall, trace_id: str | None
) -> None:
"""执行工具:内置纯函数或副作用落库;结果与错误如实记录。"""
spec = BUILTIN_TOOLS.get(call.tool_name)
call.status = "executing"
try:
if call.tool_name == "record-note":
from app.memories import service as memory_service
candidate, _memory = await memory_service.submit_candidate(
db,
domain_id,
{
"kind": "fact",
"sensitivity": "low",
"source": "auto",
"payload": {"text": str(call.input.get("text", ""))},
"source_ref": {"tool_call_id": call.id},
},
trace_id,
)
call.output = {"memory_candidate_id": candidate.id, "status": candidate.status}
else:
assert spec is not None and spec["handler"] is not None
call.output = spec["handler"](call.input)
call.status = "succeeded"
except HTTPException:
raise
except Exception as exc: # 工具失败如实记录,不丢弃不伪装
call.status = "failed"
call.error = str(exc)[:500]
call.completed_at = datetime.now(UTC)
db.add(
AuditEvent(
actor_type="system", actor_id="tool-runtime", action="tool.call.executed",
aggregate_type="tool_call", aggregate_id=call.id,
payload={"status": call.status}, trace_id=trace_id,
)
)