"""工具域路由(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]}