253 lines
9.0 KiB
Python
253 lines
9.0 KiB
Python
"""工具调用 API(api-spec §15)。
|
||
|
||
- GET /tools:列出已注册工具策略元数据(从 tool_policies 表,含 executable 派生)。
|
||
- POST /tools:注册自定义工具策略(百工枢器用治理声明,记审计)。
|
||
- GET /tools/calls/{tool_call_id}:单次工具调用事件详情。
|
||
- PATCH /tools/{tool_name}:编辑工具策略元数据(spec §15: risk_level 等)。
|
||
- DELETE /tools/{tool_name}:注销自定义工具(builtin 不可删)。
|
||
- POST /tools/{tool_name}/invoke:调用工具;高风险返回 requires_confirmation + confirmation_id,
|
||
交由 /safety/confirmations/{id}/approve 消费。
|
||
- POST /tools/{tool_name}/invoke/stream:流式调用(SSE),实时推送 tool.stdout/tool.stderr
|
||
增量与 tool.completed;司命 deny/ask-未确认/沙箱错误分别以 tool.failed / tool.requires_confirmation 结束流。
|
||
|
||
策略写操作(POST/PATCH/DELETE)后刷新 ToolRuntime 的司命决策器,使变更即时生效;
|
||
tool_runtime 未就绪时仅写库(重启后从 db 加载)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Response, status
|
||
from pydantic import BaseModel
|
||
from sse_starlette.sse import EventSourceResponse
|
||
|
||
from app.api.v1.deps import get_confirmation_store, get_optional_tool_runtime, get_tool_runtime
|
||
from app.application.tool_runtime.runtime import ToolRuntime
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.integrations.sandbox.exceptions import SandboxError
|
||
from app.models.safety import PendingConfirmationRecord
|
||
from app.modules.safety.confirmation import (
|
||
ConfirmationStore,
|
||
PendingConfirmation,
|
||
new_confirmation_id,
|
||
)
|
||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
|
||
from app.schemas.safety import (
|
||
CreateToolPolicyRequest,
|
||
ToolCallDetail,
|
||
ToolPolicyResponse,
|
||
UpdateToolPolicyRequest,
|
||
)
|
||
from app.schemas.skills import ToolCapability
|
||
from app.services import governance as governance_service
|
||
from app.services import skills as skills_service
|
||
|
||
router = APIRouter(prefix="/tools", tags=["tools"])
|
||
|
||
|
||
class InvokeRequest(BaseModel):
|
||
input: dict[str, Any]
|
||
run_id: str | None = None
|
||
character_id: str | None = None
|
||
trace_id: str | None = None
|
||
confirmed: bool = False
|
||
|
||
|
||
class ToolResultPayload(BaseModel):
|
||
ok: bool
|
||
exit_code: int | None = None
|
||
stdout: str = ""
|
||
stderr: str = ""
|
||
duration_ms: int = 0
|
||
timeout: bool = False
|
||
truncated: bool = False
|
||
trace_id: str = ""
|
||
|
||
|
||
class CompletedResponse(BaseModel):
|
||
status: str = "completed"
|
||
result: ToolResultPayload
|
||
|
||
|
||
class RequiresConfirmationResponse(BaseModel):
|
||
status: str = "requires_confirmation"
|
||
confirmation_id: str
|
||
risk_level: str
|
||
reason: str
|
||
|
||
|
||
async def _sync_confirmation_record(pending: PendingConfirmation) -> None:
|
||
"""把 InMemoryConfirmationStore 的待确认记录同步持久化到 DB。"""
|
||
async with AsyncSessionLocal() as session:
|
||
record = PendingConfirmationRecord(
|
||
id=pending.confirmation_id,
|
||
tool=pending.tool,
|
||
input_json=json.dumps(pending.input) if pending.input else "{}",
|
||
risk_level=pending.risk_level,
|
||
reason=pending.reason or "",
|
||
run_id=pending.run_id,
|
||
character_id=pending.character_id,
|
||
trace_id=pending.trace_id,
|
||
status="pending",
|
||
)
|
||
session.add(record)
|
||
await session.commit()
|
||
|
||
|
||
# —— §15 工具策略治理 ——
|
||
|
||
|
||
@router.get("", response_model=list[ToolPolicyResponse])
|
||
async def list_tools() -> list[ToolPolicyResponse]:
|
||
return await governance_service.list_tool_policies()
|
||
|
||
|
||
@router.get("/capabilities", response_model=list[ToolCapability])
|
||
async def list_capabilities(
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> list[ToolCapability]:
|
||
"""统一列举所有能力(builtin / mcp / skill,含来源标记,skills-design §6)。"""
|
||
return await skills_service.list_all_capabilities(runtime)
|
||
|
||
|
||
@router.post("", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
|
||
async def register_tool(
|
||
payload: CreateToolPolicyRequest,
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> ToolPolicyResponse:
|
||
response = await governance_service.create_tool_policy(payload)
|
||
if runtime is not None:
|
||
await runtime.reload_governor()
|
||
return response
|
||
|
||
|
||
@router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
|
||
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
|
||
return await governance_service.get_tool_call(tool_call_id)
|
||
|
||
|
||
@router.patch("/{tool_name}", response_model=ToolPolicyResponse)
|
||
async def update_tool(
|
||
tool_name: str,
|
||
payload: UpdateToolPolicyRequest,
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> ToolPolicyResponse:
|
||
response = await governance_service.update_tool_policy(tool_name, payload)
|
||
if runtime is not None:
|
||
await runtime.reload_governor()
|
||
return response
|
||
|
||
|
||
@router.delete("/{tool_name}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_tool(
|
||
tool_name: str,
|
||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||
) -> Response:
|
||
await governance_service.delete_tool_policy(tool_name)
|
||
if runtime is not None:
|
||
await runtime.reload_governor()
|
||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
# —— §15 工具调用 ——
|
||
|
||
|
||
@router.post("/{tool_name}/invoke", response_model=None)
|
||
async def invoke_tool(
|
||
tool_name: str,
|
||
body: InvokeRequest,
|
||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||
confirmations: ConfirmationStore = Depends(get_confirmation_store),
|
||
) -> CompletedResponse | RequiresConfirmationResponse:
|
||
try:
|
||
result = await runtime.execute(
|
||
tool_name,
|
||
body.input,
|
||
run_id=body.run_id,
|
||
character_id=body.character_id,
|
||
trace_id=body.trace_id,
|
||
confirmed=body.confirmed,
|
||
)
|
||
except RequiresConfirmation as exc:
|
||
pending = await confirmations.create(
|
||
PendingConfirmation(
|
||
confirmation_id=new_confirmation_id(),
|
||
tool=tool_name,
|
||
input=body.input,
|
||
risk_level=exc.risk_level or "L2",
|
||
reason=exc.message,
|
||
run_id=body.run_id,
|
||
character_id=body.character_id,
|
||
trace_id=body.trace_id,
|
||
)
|
||
)
|
||
# 同步持久化 PendingConfirmationRecord(供 safety/confirmations 新端点查找)
|
||
await _sync_confirmation_record(pending)
|
||
return RequiresConfirmationResponse(
|
||
confirmation_id=pending.confirmation_id,
|
||
risk_level=pending.risk_level,
|
||
reason=pending.reason,
|
||
)
|
||
|
||
return CompletedResponse(
|
||
result=ToolResultPayload(
|
||
ok=result.ok,
|
||
exit_code=result.exit_code,
|
||
stdout=result.stdout,
|
||
stderr=result.stderr,
|
||
duration_ms=result.duration_ms,
|
||
timeout=result.timeout,
|
||
truncated=result.truncated,
|
||
trace_id=result.trace_id,
|
||
)
|
||
)
|
||
|
||
|
||
@router.post("/{tool_name}/invoke/stream")
|
||
async def invoke_tool_stream(
|
||
tool_name: str,
|
||
body: InvokeRequest,
|
||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||
) -> EventSourceResponse:
|
||
"""流式调用工具(SSE)。
|
||
|
||
事件:``tool.stdout`` / ``tool.stderr``(data 为 ``{"data":"<base64>"}``,前端解码)、
|
||
``tool.completed``(data 为结束汇总 JSON)。司命 deny → ``tool.failed``;
|
||
ask 且未确认 → ``tool.requires_confirmation``(前端应先走非流式 invoke 完成 confirmation,
|
||
再以 confirmed=True 调用本端点);沙箱错误 → ``tool.failed``。
|
||
"""
|
||
|
||
async def event_gen():
|
||
try:
|
||
async for ev in runtime.execute_stream(
|
||
tool_name,
|
||
body.input,
|
||
run_id=body.run_id,
|
||
character_id=body.character_id,
|
||
trace_id=body.trace_id,
|
||
confirmed=body.confirmed,
|
||
):
|
||
if ev.kind in ("stdout", "stderr"):
|
||
yield {"event": f"tool.{ev.kind}", "data": json.dumps({"data": ev.payload})}
|
||
elif ev.kind == "done":
|
||
yield {"event": "tool.completed", "data": json.dumps(ev.payload)}
|
||
except RequiresConfirmation as exc:
|
||
yield {
|
||
"event": "tool.requires_confirmation",
|
||
"data": json.dumps({"risk_level": exc.risk_level, "reason": exc.message}),
|
||
}
|
||
except PermissionDenied as exc:
|
||
yield {
|
||
"event": "tool.failed",
|
||
"data": json.dumps({"error_code": "permission_denied", "error_message": str(exc)}),
|
||
}
|
||
except SandboxError as exc:
|
||
yield {
|
||
"event": "tool.failed",
|
||
"data": json.dumps({"error_code": exc.code, "error_message": str(exc)}),
|
||
}
|
||
|
||
return EventSourceResponse(event_gen())
|