- 迁移 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、幂等重放、预算硬上限
293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""工具域:注册表、内置工具、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,
|
||
)
|
||
)
|