Files
ai-ticket-platform/backend/apps/tools/services.py
2026-07-27 19:02:37 +08:00

285 lines
11 KiB
Python

"""统一 Tool/MCP 同步、授权、风险确认和调用审计服务。"""
import json
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from django.db import transaction
from django.utils import timezone
from apps.agents.models import AgentRun
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import decrypt_secret
from apps.skills.services import validate_payload
from apps.tools.models import (
ToolAuthorization,
ToolCall,
ToolCallStatus,
ToolConfirmation,
ToolProvider,
ToolProviderType,
ToolRiskLevel,
UnifiedTool,
)
class ToolExecutionError(ValueError):
pass
def _validate_http_url(value: str) -> None:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
raise ToolExecutionError("MCP 地址必须是无内嵌凭证的 HTTP(S) URL")
def _json_rpc(provider: ToolProvider, method: str, params: dict) -> dict:
_validate_http_url(provider.base_url)
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if provider.token_encrypted:
headers["Authorization"] = f"Bearer {decrypt_secret(provider.token_encrypted)}"
payload = json.dumps(
{"jsonrpc": "2.0", "id": "ai-ticket-platform", "method": method, "params": params}
).encode()
request = Request(provider.base_url, data=payload, headers=headers, method="POST")
try:
with urlopen(request, timeout=provider.timeout_seconds) as response:
result = json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as error:
raise ToolExecutionError("HTTP MCP 调用失败") from error
if not isinstance(result, dict) or result.get("error"):
raise ToolExecutionError("HTTP MCP 返回错误")
rpc_result = result.get("result")
if not isinstance(rpc_result, dict):
raise ToolExecutionError("HTTP MCP 返回结构无效")
return rpc_result
@transaction.atomic
def sync_mcp_tools(provider: ToolProvider) -> list[UnifiedTool]:
"""从 HTTP MCP 同步工具定义,但保留本地风险等级、开关和授权治理。"""
provider = ToolProvider.objects.select_for_update().get(id=provider.id)
if not provider.enabled or provider.provider_type != ToolProviderType.MCP_HTTP:
raise ToolExecutionError("MCP 服务未启用或类型不正确")
result = _json_rpc(provider, "tools/list", {})
remote_tools = result.get("tools")
if not isinstance(remote_tools, list):
raise ToolExecutionError("MCP tools/list 未返回工具列表")
synced = []
now = timezone.now()
for item in remote_tools:
if not isinstance(item, dict) or not str(item.get("name") or "").strip():
continue
external_name = str(item["name"]).strip()
code = f"mcp.{provider.id}.{external_name}"
tool, _ = UnifiedTool.objects.update_or_create(
code=code,
defaults={
"provider": provider,
"external_name": external_name,
"name": str(item.get("title") or external_name),
"description": str(item.get("description") or ""),
"input_schema": item.get("inputSchema")
if isinstance(item.get("inputSchema"), dict)
else {"type": "object"},
"synced_at": now,
},
)
synced.append(tool)
OperationAuditLog.objects.create(
module="tools",
action="mcp_tools_synced",
target_type="tool_provider",
target_id=str(provider.id),
request_snapshot={"tool_count": len(synced)},
result="success",
)
return synced
def _authorization_snapshot(tool: UnifiedTool, user, run: AgentRun | None) -> dict:
"""计算并冻结用户、用户组、Agent 动作与 Agent 授权的交集。"""
if not user or not user.is_authenticated:
raise ToolExecutionError("工具调用需要登录用户")
if not (user.is_superuser or user.has_perm("system.tool.invoke")):
raise ToolExecutionError("用户没有工具调用权限")
authorizations = ToolAuthorization.objects.filter(tool=tool)
group_ids = user.groups.values_list("id", flat=True)
explicitly_denied = authorizations.filter(
allowed=False,
principal_type="user",
principal_id=user.id,
).exists() or authorizations.filter(
allowed=False,
principal_type="group",
principal_id__in=group_ids,
).exists()
grants = authorizations.filter(allowed=True)
user_allowed = not explicitly_denied and (
user.is_superuser or grants.filter(
principal_type="user", principal_id=user.id
).exists() or grants.filter(
principal_type="group", principal_id__in=group_ids
).exists())
if not user_allowed:
raise ToolExecutionError("用户或用户组未获得该工具授权")
agent_allowed = True
agent_grant = True
if run is not None:
action = f"tool.call:{tool.code}"
agent_allowed = action in run.config_snapshot.get("agent", {}).get(
"allowed_actions", []
)
agent_grant = grants.filter(
principal_type="agent",
principal_id=run.definition_id,
).exists()
if not agent_allowed or not agent_grant:
raise ToolExecutionError("Agent 版本或 Agent 授权未允许该工具")
return {
"user_id": user.id,
"user_permission": True,
"principal_grant": user_allowed,
"agent_run_id": str(run.public_id) if run else None,
"agent_action_allowed": agent_allowed,
"agent_grant": agent_grant,
}
def execute_tool_call(call: ToolCall) -> ToolCall:
"""执行已通过授权和风险确认的工具调用,并持久化终态审计。"""
call = ToolCall.objects.select_related("tool__provider").get(id=call.id)
if call.status not in {ToolCallStatus.RUNNING, ToolCallStatus.PENDING_CONFIRMATION}:
return call
call.status = ToolCallStatus.RUNNING
call.started_at = timezone.now()
call.save(update_fields=["status", "started_at"])
try:
if not call.tool.provider_id:
raise ToolExecutionError("原生工具尚未注册执行器")
result = _json_rpc(
call.tool.provider,
"tools/call",
{"name": call.tool.external_name, "arguments": call.input_payload},
)
except Exception as error:
call.status = ToolCallStatus.FAILED
call.error_code = "tool_call_failed"
call.error_message = str(error)
else:
call.status = ToolCallStatus.SUCCEEDED
call.output_payload = result
call.finished_at = timezone.now()
call.save()
OperationAuditLog.objects.create(
user=call.requested_by,
module="tools",
action="tool_call_finished",
target_type="tool_call",
target_id=str(call.public_id),
request_snapshot={
"tool_code": call.tool.code,
"status": call.status,
"risk_level": call.tool.risk_level,
"agent_run_id": str(call.agent_run.public_id) if call.agent_run_id else None,
},
result="success" if call.status == ToolCallStatus.SUCCEEDED else "failed",
)
return call
@transaction.atomic
def request_tool_call(
tool: UnifiedTool,
payload: dict,
*,
user,
run: AgentRun | None = None,
idempotency_key: str = "",
) -> ToolCall:
"""创建幂等工具调用;高风险调用先进入独立人工确认,不直接执行。"""
tool = UnifiedTool.objects.select_for_update().select_related("provider").get(id=tool.id)
if not tool.enabled or (tool.provider_id and not tool.provider.enabled):
raise ToolExecutionError("工具或工具服务未启用")
if idempotency_key:
existing = ToolCall.objects.filter(tool=tool, idempotency_key=idempotency_key).first()
if existing is not None:
return existing
validate_payload(tool.input_schema, payload, "工具输入")
authorization = _authorization_snapshot(tool, user, run)
needs_confirmation = tool.risk_level == ToolRiskLevel.HIGH_RISK
call_values = {
"agent_run": run,
"requested_by": user,
"status": (
ToolCallStatus.PENDING_CONFIRMATION
if needs_confirmation
else ToolCallStatus.RUNNING
),
"input_payload": payload,
"authorization_snapshot": authorization,
"risk_snapshot": {
"risk_level": tool.risk_level,
"confirmation_required": needs_confirmation,
},
}
normalized_key = idempotency_key.strip()
if normalized_key:
call, created = ToolCall.objects.get_or_create(
tool=tool,
idempotency_key=normalized_key,
defaults=call_values,
)
if not created:
return call
else:
call = ToolCall.objects.create(
tool=tool,
idempotency_key="",
**call_values,
)
if needs_confirmation:
ToolConfirmation.objects.create(call=call)
from apps.workitems.services import sync_tool_confirmation_work_item
sync_tool_confirmation_work_item(call, actor=user)
return call
transaction.on_commit(lambda: execute_tool_call(call))
return call
@transaction.atomic
def decide_tool_confirmation(call: ToolCall, *, reviewer, decision: str, reason: str = ""):
"""由非申请人审批高风险调用,并仅在事务提交后开始执行。"""
call = ToolCall.objects.select_for_update().select_related("confirmation").get(id=call.id)
if call.status != ToolCallStatus.PENDING_CONFIRMATION:
raise ToolExecutionError("工具调用不在待确认状态")
if call.requested_by_id == reviewer.id:
raise ToolExecutionError("高风险工具调用不能由申请人自审")
if decision not in {"approve", "reject"}:
raise ToolExecutionError("确认决定必须是 approve 或 reject")
confirmation = call.confirmation
confirmation.status = "approved" if decision == "approve" else "rejected"
confirmation.reason = reason.strip()
confirmation.decided_by = reviewer
confirmation.decided_at = timezone.now()
confirmation.save()
if decision == "reject":
call.status = ToolCallStatus.REJECTED
call.finished_at = timezone.now()
call.save(update_fields=["status", "finished_at"])
else:
call.status = ToolCallStatus.RUNNING
call.save(update_fields=["status"])
transaction.on_commit(lambda: execute_tool_call(call))
from apps.workitems.services import sync_tool_confirmation_work_item
sync_tool_confirmation_work_item(call, actor=reviewer)
return call