- 迁移 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、幂等重放、预算硬上限
113 lines
3.6 KiB
Python
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]}
|