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

113 lines
3.6 KiB
Python

"""工具域路由(WP-06)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import Tool, ToolCall, User
from app.tools import service
tools_router = APIRouter(prefix="/v1/tools", tags=["tools"])
calls_router = APIRouter(prefix="/v1", tags=["tool-calls"])
def _tool_out(t: Tool) -> dict[str, Any]:
return {
"name": t.name,
"description": t.description,
"input_schema": t.input_schema,
"risk_level": t.risk_level,
"requires_approval": t.requires_approval,
"enabled": t.enabled,
}
def _call_out(c: ToolCall) -> dict[str, Any]:
return {
"id": c.id,
"run_id": c.run_id,
"tool_name": c.tool_name,
"input": c.input,
"output": c.output,
"status": c.status,
"error": c.error,
"created_at": c.created_at.isoformat(),
"completed_at": c.completed_at.isoformat() if c.completed_at else None,
}
@tools_router.get("")
async def list_tools(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
await service.seed_builtin_tools(db) # 幂等登记(后续由 Registry 管理取代)
rows = list((await db.scalars(select(Tool).order_by(Tool.name.asc()))).all())
return {"data": [_tool_out(t) for t in rows]}
@calls_router.post("/runs/{run_id}/tool-calls", status_code=201, response_model=None)
async def create_tool_call(
run_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
) -> dict[str, Any] | JSONResponse:
domain = await _domain_of(db, user)
if idempotency_key:
body = {**body, "idempotency_key": idempotency_key}
call, replayed = await service.create_tool_call(
db, domain.id, run_id, body, getattr(request.state, "trace_id", None)
)
if replayed:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=200, content=_call_out(call))
return _call_out(call)
@calls_router.post("/tool-calls/{tool_call_id}/approvals", status_code=201)
async def decide_approval(
tool_call_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, bool],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
call = await service.decide_approval(
db, domain.id, tool_call_id, bool(body.get("approve")),
getattr(request.state, "trace_id", None),
)
return _call_out(call)
@calls_router.get("/tool-calls")
async def list_tool_calls(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
status_filter: str = "pending_approval",
) -> dict[str, Any]:
if status_filter not in {
"pending_approval", "approved", "denied", "executing", "succeeded", "failed",
}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "status 取值不合法")
domain = await _domain_of(db, user)
from app.models import Run
stmt = (
select(ToolCall)
.join(Run, ToolCall.run_id == Run.id)
.where(Run.personal_domain_id == domain.id, ToolCall.status == status_filter)
.order_by(ToolCall.created_at.asc())
)
rows = list((await db.scalars(stmt)).all())
return {"data": [_call_out(c) for c in rows]}