"""工具域:注册表、内置工具、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, ) )