diff --git a/backend/apps/agents/migrations/0005_agentversion_allowed_actions.py b/backend/apps/agents/migrations/0005_agentversion_allowed_actions.py new file mode 100644 index 0000000..ff969a7 --- /dev/null +++ b/backend/apps/agents/migrations/0005_agentversion_allowed_actions.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-27 04:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agents', '0004_agentrunevent'), + ] + + operations = [ + migrations.AddField( + model_name='agentversion', + name='allowed_actions', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/backend/apps/agents/migrations/0006_step_model_deployments.py b/backend/apps/agents/migrations/0006_step_model_deployments.py new file mode 100644 index 0000000..bdb39a5 --- /dev/null +++ b/backend/apps/agents/migrations/0006_step_model_deployments.py @@ -0,0 +1,34 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("agents", "0005_agentversion_allowed_actions"), + ("modelhub", "0002_modelprovider_image_egress_approved_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="agentstepbinding", + name="model_deployment", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="agent_step_bindings", + to="modelhub.modeldeployment", + ), + ), + migrations.AddField( + model_name="agentrunstep", + name="model_deployment", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="agent_run_steps", + to="modelhub.modeldeployment", + ), + ), + ] diff --git a/backend/apps/modelhub/api.py b/backend/apps/modelhub/api.py index f757fd2..ba48f7d 100644 --- a/backend/apps/modelhub/api.py +++ b/backend/apps/modelhub/api.py @@ -158,7 +158,7 @@ def create_deployment(request, payload: ModelDeploymentIn): raise HttpError(400, "Temperature must be between 0 and 2") if not 1 <= payload.max_output_tokens <= 32768: raise HttpError(400, "Max output tokens are out of range") - if payload.capability not in {"text_chat", "vision_chat"}: + if payload.capability not in {"text_chat", "vision_chat", "embedding", "rerank"}: raise HttpError(400, "Unknown model capability") deployment = ModelDeployment.objects.create(**payload.dict()) deployment = ModelDeployment.objects.select_related("provider").get(id=deployment.id) @@ -177,7 +177,12 @@ def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdat require_permission(request, "model.manage") deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id) values = payload.dict(exclude_unset=True) - if "capability" in values and values["capability"] not in {"text_chat", "vision_chat"}: + if "capability" in values and values["capability"] not in { + "text_chat", + "vision_chat", + "embedding", + "rerank", + }: raise HttpError(400, "Unknown model capability") for field, value in values.items(): setattr(deployment, field, value) @@ -196,6 +201,8 @@ def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdat def test_deployment(request, deployment_id: int, payload: ModelTestIn): require_permission(request, "model.manage") deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id) + if deployment.capability == "embedding": + raise HttpError(400, "Embedding 模型请通过知识检索验证") invocation = create_model_test(deployment, payload.prompt, request.user) run_model_test.delay(invocation.id) invocation.refresh_from_db() diff --git a/backend/apps/modelhub/services.py b/backend/apps/modelhub/services.py index 93c8f22..9cf44a0 100644 --- a/backend/apps/modelhub/services.py +++ b/backend/apps/modelhub/services.py @@ -1,3 +1,9 @@ +"""模型调用执行器。 + +统一处理超时预算、可重试错误、凭据脱敏、调用快照和 Token/耗时记录。 +""" + +from collections.abc import Callable from dataclasses import dataclass from time import monotonic, sleep from typing import Any @@ -38,12 +44,18 @@ def _request_completion( *, temperature: float, max_output_tokens: int, + timeout_seconds: float | None = None, ) -> CompletionResult: provider = deployment.provider + request_timeout = min(max(provider.default_timeout_seconds, 1), 300) + if timeout_seconds is not None: + request_timeout = min(request_timeout, max(timeout_seconds, 1)) client = OpenAI( api_key=decrypt_secret(provider.api_key_encrypted), base_url=provider.base_url, - timeout=min(max(provider.default_timeout_seconds, 1), 300), + timeout=request_timeout, + # Retry here so the platform can expose it instead of waiting on opaque SDK retries. + max_retries=0, ) response = client.chat.completions.create( model=deployment.model_name, @@ -84,7 +96,11 @@ def execute_invocation( *, temperature: float | None = None, max_output_tokens: int | None = None, + on_retry: Callable[[int, str], None] | None = None, + total_timeout_seconds: float | None = None, ) -> ModelInvocation: + """在总时间预算内调用模型,分类错误、限制重试并记录脱敏终态。""" + deployment = invocation.deployment if deployment is None or not deployment.enabled or not deployment.provider.enabled: invocation.status = "failed" @@ -111,6 +127,12 @@ def execute_invocation( result: CompletionResult | None = None last_error: Exception | None = None for attempt in range(2): + elapsed = monotonic() - started + remaining = total_timeout_seconds - elapsed if total_timeout_seconds is not None else None + if remaining is not None and remaining <= 0: + invocation.error_code = "timeout" + invocation.error_message = "模型请求超过 Agent 时间预算" + break try: result = _request_completion( deployment, @@ -119,6 +141,7 @@ def execute_invocation( if temperature is not None else deployment.default_temperature, max_output_tokens=max_output_tokens or deployment.max_output_tokens, + timeout_seconds=remaining, ) invocation.retry_count = attempt break @@ -130,6 +153,18 @@ def execute_invocation( invocation.error_code = code invocation.error_message = message break + if total_timeout_seconds is not None: + remaining_after_error = total_timeout_seconds - (monotonic() - started) + if remaining_after_error <= 1: + invocation.error_code = code + invocation.error_message = message + break + invocation.retry_count = attempt + 1 + invocation.error_code = code + invocation.error_message = message + invocation.save(update_fields=["retry_count", "error_code", "error_message"]) + if on_retry: + on_retry(attempt + 1, code) sleep(1) invocation.duration_ms = max(int((monotonic() - started) * 1000), 0) @@ -156,6 +191,8 @@ def create_model_test( prompt: str, user=None, ) -> ModelInvocation: + """创建可审计的异步模型连通测试,不绕过正式模型执行器。""" + return ModelInvocation.objects.create( deployment=deployment, invocation_type="model_test", diff --git a/backend/apps/modelhub/tests/test_modelhub_api.py b/backend/apps/modelhub/tests/test_modelhub_api.py index a836762..194a4ee 100644 --- a/backend/apps/modelhub/tests/test_modelhub_api.py +++ b/backend/apps/modelhub/tests/test_modelhub_api.py @@ -89,6 +89,45 @@ def test_model_test_runs_through_recorded_invocation(client, db, monkeypatch): assert response.json()["output_tokens"] == 4 +def test_embedding_deployment_can_be_configured_but_uses_knowledge_search_for_test(client, db): + login_admin(client) + provider_id = create_provider(client).json()["id"] + deployment = client.post( + "/api/modelhub/deployments", + data={ + "provider_id": provider_id, + "display_name": "知识向量模型", + "model_name": "embedding-model", + "capability": "embedding", + }, + content_type="application/json", + ) + + assert deployment.status_code == 200 + assert deployment.json()["capability"] == "embedding" + + response = client.post( + f"/api/modelhub/deployments/{deployment.json()['id']}/test", + data={"prompt": "测试连接"}, + content_type="application/json", + ) + assert response.status_code == 400 + assert "知识检索" in response.json()["detail"] + + rerank = client.post( + "/api/modelhub/deployments", + data={ + "provider_id": provider_id, + "display_name": "知识重排模型", + "model_name": "rerank-model", + "capability": "rerank", + }, + content_type="application/json", + ) + assert rerank.status_code == 200 + assert rerank.json()["capability"] == "rerank" + + def test_non_ai_admin_cannot_manage_modelhub(client, db): user = get_user_model().objects.create_user(username="requester", password="password") client.force_login(user) diff --git a/backend/apps/modelhub/tests/test_services.py b/backend/apps/modelhub/tests/test_services.py index aacfc0b..afe07e0 100644 --- a/backend/apps/modelhub/tests/test_services.py +++ b/backend/apps/modelhub/tests/test_services.py @@ -27,6 +27,7 @@ def create_invocation(): def test_transient_error_retries_once(db, monkeypatch): invocation = create_invocation() calls = [] + retries = [] def request(*args, **kwargs): calls.append(1) @@ -37,12 +38,17 @@ def test_transient_error_retries_once(db, monkeypatch): monkeypatch.setattr("apps.modelhub.services._request_completion", request) monkeypatch.setattr("apps.modelhub.services.sleep", lambda *_: None) - execute_invocation(invocation, invocation.input_snapshot["messages"]) + execute_invocation( + invocation, + invocation.input_snapshot["messages"], + on_retry=lambda retry_number, code: retries.append((retry_number, code)), + ) invocation.refresh_from_db() assert len(calls) == 2 assert invocation.status == "succeeded" assert invocation.retry_count == 1 + assert retries == [(1, "timeout")] def test_non_transient_error_fails_without_exposing_raw_error(db, monkeypatch): @@ -61,6 +67,31 @@ def test_non_transient_error_fails_without_exposing_raw_error(db, monkeypatch): assert "secret provider detail" not in invocation.error_message +def test_agent_time_budget_does_not_repeat_a_full_timeout(db, monkeypatch): + invocation = create_invocation() + calls = [] + clock = iter([0.0, 0.0, 19.5, 20.0]) + + def request(*args, **kwargs): + calls.append(kwargs["timeout_seconds"]) + raise APITimeoutError(request=None) + + monkeypatch.setattr("apps.modelhub.services._request_completion", request) + monkeypatch.setattr("apps.modelhub.services.monotonic", lambda: next(clock)) + + execute_invocation( + invocation, + invocation.input_snapshot["messages"], + total_timeout_seconds=20, + ) + + invocation.refresh_from_db() + assert calls == [20] + assert invocation.status == "failed" + assert invocation.error_code == "timeout" + assert invocation.retry_count == 0 + + def provider_error(error_type, status_code): request = httpx.Request("POST", "https://models.example.com/v1/chat/completions") response = httpx.Response(status_code, request=request) diff --git a/backend/apps/tools/__init__.py b/backend/apps/tools/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/tools/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/tools/api.py b/backend/apps/tools/api.py new file mode 100644 index 0000000..29a3f1c --- /dev/null +++ b/backend/apps/tools/api.py @@ -0,0 +1,273 @@ +from django.db import transaction +from ninja import Router +from ninja.errors import HttpError + +from apps.accounts.api import require_permission +from apps.agents.models import AgentRun +from apps.modelhub.crypto import encrypt_secret +from apps.skills.services import SkillValidationError +from apps.tools.models import ( + ToolAuthorization, + ToolCall, + ToolProvider, + UnifiedTool, +) +from apps.tools.schemas import ( + ToolAuthorizationIn, + ToolAuthorizationOut, + ToolCallIn, + ToolCallOut, + ToolConfirmationIn, + ToolProviderIn, + ToolProviderOut, + ToolProviderUpdateIn, + UnifiedToolOut, + UnifiedToolUpdateIn, +) +from apps.tools.services import ( + ToolExecutionError, + decide_tool_confirmation, + request_tool_call, + sync_mcp_tools, +) + +router = Router(tags=["tools"]) + + +def serialize_provider(provider: ToolProvider) -> dict: + return { + "id": provider.id, + "name": provider.name, + "provider_type": provider.provider_type, + "base_url": provider.base_url, + "credential_set": bool(provider.token_encrypted), + "enabled": provider.enabled, + "timeout_seconds": provider.timeout_seconds, + "created_at": provider.created_at, + "updated_at": provider.updated_at, + } + + +def serialize_call(call: ToolCall) -> dict: + confirmation = getattr(call, "confirmation", None) + return { + "public_id": call.public_id, + "tool_code": call.tool.code, + "requested_by_id": call.requested_by_id, + "requested_by_name": ( + call.requested_by.get_full_name() or call.requested_by.username + if call.requested_by + else "" + ), + "status": call.status, + "input_payload": call.input_payload, + "output_payload": call.output_payload, + "authorization_snapshot": call.authorization_snapshot, + "risk_snapshot": call.risk_snapshot, + "error_code": call.error_code, + "error_message": call.error_message, + "confirmation_status": confirmation.status if confirmation else None, + "confirmation_reason": confirmation.reason if confirmation else "", + "created_at": call.created_at, + "started_at": call.started_at, + "finished_at": call.finished_at, + } + + +@router.get("/providers", response=list[ToolProviderOut]) +def list_tool_providers(request): + require_permission(request, "tool.manage") + return [serialize_provider(item) for item in ToolProvider.objects.all()] + + +@router.post("/providers", response=ToolProviderOut) +def create_tool_provider(request, payload: ToolProviderIn): + require_permission(request, "tool.manage") + if payload.provider_type == "mcp_http" and not payload.base_url: + raise HttpError(400, "HTTP MCP 必须配置服务地址") + provider = ToolProvider.objects.create( + name=payload.name, + provider_type=payload.provider_type, + base_url=payload.base_url, + token_encrypted=encrypt_secret(payload.token) if payload.token else "", + enabled=payload.enabled, + timeout_seconds=min(max(payload.timeout_seconds, 1), 300), + created_by=request.user, + ) + return serialize_provider(provider) + + +@router.patch("/providers/{provider_id}", response=ToolProviderOut) +def update_tool_provider( + request, + provider_id: int, + payload: ToolProviderUpdateIn, +): + require_permission(request, "tool.manage") + provider = ToolProvider.objects.filter(id=provider_id).first() + if provider is None: + raise HttpError(404, "工具服务不存在") + values = payload.dict(exclude_unset=True) + if "base_url" in values and provider.provider_type == "mcp_http": + if not values["base_url"]: + raise HttpError(400, "HTTP MCP 必须配置服务地址") + token = values.pop("token", None) + if token: + provider.token_encrypted = encrypt_secret(token) + if "timeout_seconds" in values: + values["timeout_seconds"] = min(max(values["timeout_seconds"], 1), 300) + for field, value in values.items(): + setattr(provider, field, value) + provider.save() + return serialize_provider(provider) + + +@router.post("/providers/{provider_id}/sync", response=list[UnifiedToolOut]) +def sync_tool_provider(request, provider_id: int): + require_permission(request, "tool.manage") + provider = ToolProvider.objects.filter(id=provider_id).first() + if provider is None: + raise HttpError(404, "工具服务不存在") + try: + return sync_mcp_tools(provider) + except ToolExecutionError as error: + raise HttpError(400, str(error)) from error + + +@router.get("", response=list[UnifiedToolOut]) +def list_tools(request): + require_permission(request, "tool.view") + return UnifiedTool.objects.select_related("provider").all() + + +@router.get( + "/{tool_id}/authorizations", + response=list[ToolAuthorizationOut], +) +def list_tool_authorizations(request, tool_id: int): + require_permission(request, "tool.manage") + if not UnifiedTool.objects.filter(id=tool_id).exists(): + raise HttpError(404, "工具不存在") + return ToolAuthorization.objects.filter(tool_id=tool_id) + + +@router.post( + "/{tool_id}/authorizations", + response=ToolAuthorizationOut, +) +def create_tool_authorization(request, tool_id: int, payload: ToolAuthorizationIn): + require_permission(request, "tool.manage") + if payload.principal_type not in {"user", "group", "agent"}: + raise HttpError(400, "授权主体必须是 user、group 或 agent") + tool = UnifiedTool.objects.filter(id=tool_id).first() + if tool is None: + raise HttpError(404, "工具不存在") + authorization, _ = ToolAuthorization.objects.update_or_create( + tool=tool, + principal_type=payload.principal_type, + principal_id=payload.principal_id, + defaults={"allowed": payload.allowed, "created_by": request.user}, + ) + return authorization + + +@router.get("/calls", response=list[ToolCallOut]) +def list_tool_calls(request, status: str = "", tool_code: str = ""): + require_permission(request, "tool.invoke") + queryset = ToolCall.objects.select_related( + "tool", + "requested_by", + "confirmation", + ) + can_confirm = request.user.is_superuser or request.user.has_perm( + "system.tool.confirm" + ) + if not can_confirm: + queryset = queryset.filter(requested_by=request.user) + if status: + queryset = queryset.filter(status=status) + if tool_code: + queryset = queryset.filter(tool__code=tool_code) + return [serialize_call(call) for call in queryset[:100]] + + +@router.post("/calls", response=ToolCallOut) +def create_tool_call(request, payload: ToolCallIn): + require_permission(request, "tool.invoke") + tool = UnifiedTool.objects.filter(code=payload.tool_code).first() + if tool is None: + raise HttpError(404, "工具不存在") + run = None + if payload.agent_run_id: + run = AgentRun.objects.filter(public_id=payload.agent_run_id).first() + if run is None: + raise HttpError(404, "Agent 运行不存在") + try: + with transaction.atomic(): + call = request_tool_call( + tool, + payload.input, + user=request.user, + run=run, + idempotency_key=payload.idempotency_key, + ) + except (ToolExecutionError, SkillValidationError) as error: + raise HttpError(400, str(error)) from error + call = ToolCall.objects.select_related("tool", "confirmation").get(id=call.id) + return serialize_call(call) + + +@router.get("/calls/{public_id}", response=ToolCallOut) +def get_tool_call(request, public_id: str): + require_permission(request, "tool.invoke") + call = ToolCall.objects.select_related("tool", "confirmation").filter( + public_id=public_id + ).first() + if call is None: + raise HttpError(404, "工具调用不存在") + if not request.user.is_superuser and call.requested_by_id != request.user.id: + require_permission(request, "tool.confirm") + return serialize_call(call) + + +@router.post("/calls/{public_id}/confirmation", response=ToolCallOut) +def confirm_tool_call(request, public_id: str, payload: ToolConfirmationIn): + require_permission(request, "tool.confirm") + call = ToolCall.objects.select_related("tool", "confirmation").filter( + public_id=public_id + ).first() + if call is None: + raise HttpError(404, "工具调用不存在") + try: + with transaction.atomic(): + decide_tool_confirmation( + call, + reviewer=request.user, + decision=payload.decision, + reason=payload.reason, + ) + except ToolExecutionError as error: + raise HttpError(400, str(error)) from error + call.refresh_from_db() + return serialize_call( + ToolCall.objects.select_related("tool", "confirmation").get(id=call.id) + ) + + +@router.patch("/{tool_id}", response=UnifiedToolOut) +def update_tool(request, tool_id: int, payload: UnifiedToolUpdateIn): + require_permission(request, "tool.manage") + tool = UnifiedTool.objects.filter(id=tool_id).first() + if tool is None: + raise HttpError(404, "工具不存在") + values = payload.dict(exclude_unset=True) + if "risk_level" in values and values["risk_level"] not in { + "read_only", + "low_write", + "high_risk", + }: + raise HttpError(400, "未知工具风险等级") + for field, value in values.items(): + setattr(tool, field, value) + tool.save() + return tool diff --git a/backend/apps/tools/apps.py b/backend/apps/tools/apps.py new file mode 100644 index 0000000..e16f7d6 --- /dev/null +++ b/backend/apps/tools/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ToolsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.tools" diff --git a/backend/apps/tools/mcp_gateway.py b/backend/apps/tools/mcp_gateway.py new file mode 100644 index 0000000..81c2abd --- /dev/null +++ b/backend/apps/tools/mcp_gateway.py @@ -0,0 +1,137 @@ +import json +import secrets + +from django.conf import settings +from django.db.models import Q +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt + +from apps.tickets.models import Ticket, TicketStatus + +TICKET_SEARCH_TOOL = { + "name": "search_tickets", + "title": "内部工单查询", + "description": "按编号、标题或问题描述查询未归档工单,可选状态过滤。", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "工单编号、标题或问题关键词", + }, + "status": { + "type": "string", + "enum": [value for value, _label in TicketStatus.choices], + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 20, + }, + }, + }, +} + + +def _rpc_error(rpc_id, code: int, message: str, *, status: int = 200): + return JsonResponse( + { + "jsonrpc": "2.0", + "id": rpc_id, + "error": {"code": code, "message": message}, + }, + status=status, + ) + + +def _authorized(request) -> bool: + expected = settings.INTERNAL_MCP_TOKEN + authorization = request.headers.get("Authorization", "") + if not expected or not authorization.startswith("Bearer "): + return False + return secrets.compare_digest(authorization.removeprefix("Bearer "), expected) + + +def _search_tickets(arguments: dict) -> dict: + query = str(arguments.get("query") or "").strip() + status = str(arguments.get("status") or "").strip() + try: + limit = min(max(int(arguments.get("limit", 10)), 1), 20) + except (TypeError, ValueError) as error: + raise ValueError("limit 必须是 1 到 20 的整数") from error + if status and status not in TicketStatus.values: + raise ValueError("status 不是有效工单状态") + queryset = Ticket.objects.select_related("category").filter(archived_at__isnull=True) + if query: + queryset = queryset.filter( + Q(ticket_no__icontains=query) + | Q(title__icontains=query) + | Q(description__icontains=query) + ) + if status: + queryset = queryset.filter(status=status) + tickets = [ + { + "id": ticket.id, + "ticket_no": ticket.ticket_no, + "title": ticket.title, + "description": ticket.description[:500], + "status": ticket.status, + "priority": ticket.priority, + "category_code": ticket.category.code if ticket.category else None, + "updated_at": ticket.updated_at.isoformat(), + } + for ticket in queryset[:limit] + ] + return { + "query": query, + "status": status or None, + "count": len(tickets), + "tickets": tickets, + } + + +@csrf_exempt +def internal_mcp_view(request): + if request.method != "POST": + return _rpc_error(None, -32600, "仅支持 POST", status=405) + if not settings.INTERNAL_MCP_TOKEN: + return _rpc_error(None, -32001, "内部 MCP 尚未配置访问令牌", status=503) + if not _authorized(request): + return _rpc_error(None, -32000, "未授权的内部 MCP 请求", status=401) + try: + payload = json.loads(request.body) + except (json.JSONDecodeError, UnicodeDecodeError): + return _rpc_error(None, -32700, "JSON 解析失败", status=400) + if not isinstance(payload, dict): + return _rpc_error(None, -32600, "JSON-RPC 请求结构无效", status=400) + rpc_id = payload.get("id") + method = payload.get("method") + params = payload.get("params") + if not isinstance(params, dict): + return _rpc_error(rpc_id, -32602, "params 必须是对象") + if method == "tools/list": + result = {"tools": [TICKET_SEARCH_TOOL]} + elif method == "tools/call": + if params.get("name") != TICKET_SEARCH_TOOL["name"]: + return _rpc_error(rpc_id, -32601, "工具不存在") + arguments = params.get("arguments", {}) + if not isinstance(arguments, dict): + return _rpc_error(rpc_id, -32602, "arguments 必须是对象") + try: + structured = _search_tickets(arguments) + except ValueError as error: + return _rpc_error(rpc_id, -32602, str(error)) + result = { + "content": [ + { + "type": "text", + "text": json.dumps(structured, ensure_ascii=False), + } + ], + "structuredContent": structured, + "isError": False, + } + else: + return _rpc_error(rpc_id, -32601, "不支持的 MCP 方法") + return JsonResponse({"jsonrpc": "2.0", "id": rpc_id, "result": result}) diff --git a/backend/apps/tools/migrations/0001_initial.py b/backend/apps/tools/migrations/0001_initial.py new file mode 100644 index 0000000..a4e5f0c --- /dev/null +++ b/backend/apps/tools/migrations/0001_initial.py @@ -0,0 +1,117 @@ +# Generated by Django 5.2.15 on 2026-07-27 05:53 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('agents', '0005_agentversion_allowed_actions'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ToolCall', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('status', models.CharField(choices=[('pending_confirmation', '待确认'), ('running', '执行中'), ('succeeded', '成功'), ('failed', '失败'), ('rejected', '已拒绝')], max_length=32)), + ('input_payload', models.JSONField(default=dict)), + ('output_payload', models.JSONField(blank=True, null=True)), + ('authorization_snapshot', models.JSONField(default=dict)), + ('risk_snapshot', models.JSONField(default=dict)), + ('idempotency_key', models.CharField(blank=True, max_length=160)), + ('error_code', models.CharField(blank=True, max_length=80)), + ('error_message', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('agent_run', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tool_calls', to='agents.agentrun')), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_tool_calls', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ToolConfirmation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(default='pending', max_length=24)), + ('reason', models.TextField(blank=True)), + ('decided_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('call', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='confirmation', to='tools.toolcall')), + ('decided_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='decided_tool_confirmations', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ToolProvider', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, unique=True)), + ('provider_type', models.CharField(choices=[('native', '原生工具'), ('mcp_http', 'HTTP MCP')], default='mcp_http', max_length=24)), + ('base_url', models.URLField(blank=True, max_length=500)), + ('token_encrypted', models.TextField(blank=True)), + ('enabled', models.BooleanField(default=True)), + ('timeout_seconds', models.PositiveIntegerField(default=30)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tool_providers', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='UnifiedTool', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=160, unique=True)), + ('external_name', models.CharField(blank=True, max_length=200)), + ('name', models.CharField(max_length=160)), + ('description', models.TextField(blank=True)), + ('input_schema', models.JSONField(default=dict)), + ('risk_level', models.CharField(choices=[('read_only', '只读'), ('low_write', '低风险写'), ('high_risk', '高风险')], default='read_only', max_length=24)), + ('enabled', models.BooleanField(default=True)), + ('synced_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('provider', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tools', to='tools.toolprovider')), + ], + options={ + 'ordering': ['code'], + }, + ), + migrations.AddField( + model_name='toolcall', + name='tool', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='calls', to='tools.unifiedtool'), + ), + migrations.CreateModel( + name='ToolAuthorization', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('principal_type', models.CharField(max_length=24)), + ('principal_id', models.PositiveBigIntegerField()), + ('allowed', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tool_authorizations', to=settings.AUTH_USER_MODEL)), + ('tool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='authorizations', to='tools.unifiedtool')), + ], + ), + migrations.AddConstraint( + model_name='toolcall', + constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key__gt', '')), fields=('tool', 'idempotency_key'), name='unique_tool_call_idempotency_key'), + ), + migrations.AddConstraint( + model_name='toolauthorization', + constraint=models.UniqueConstraint(fields=('tool', 'principal_type', 'principal_id'), name='unique_tool_authorization_principal'), + ), + ] diff --git a/backend/apps/tools/migrations/__init__.py b/backend/apps/tools/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/tools/models.py b/backend/apps/tools/models.py new file mode 100644 index 0000000..f177212 --- /dev/null +++ b/backend/apps/tools/models.py @@ -0,0 +1,161 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class ToolProviderType(models.TextChoices): + NATIVE = "native", "原生工具" + MCP_HTTP = "mcp_http", "HTTP MCP" + + +class ToolRiskLevel(models.TextChoices): + READ_ONLY = "read_only", "只读" + LOW_WRITE = "low_write", "低风险写" + HIGH_RISK = "high_risk", "高风险" + + +class ToolProvider(models.Model): + name = models.CharField(max_length=120, unique=True) + provider_type = models.CharField( + max_length=24, + choices=ToolProviderType.choices, + default=ToolProviderType.MCP_HTTP, + ) + base_url = models.URLField(max_length=500, blank=True) + token_encrypted = models.TextField(blank=True) + enabled = models.BooleanField(default=True) + timeout_seconds = models.PositiveIntegerField(default=30) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_tool_providers", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + + +class UnifiedTool(models.Model): + code = models.CharField(max_length=160, unique=True) + provider = models.ForeignKey( + ToolProvider, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="tools", + ) + external_name = models.CharField(max_length=200, blank=True) + name = models.CharField(max_length=160) + description = models.TextField(blank=True) + input_schema = models.JSONField(default=dict) + risk_level = models.CharField( + max_length=24, + choices=ToolRiskLevel.choices, + default=ToolRiskLevel.READ_ONLY, + ) + enabled = models.BooleanField(default=True) + synced_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["code"] + + +class ToolAuthorization(models.Model): + tool = models.ForeignKey( + UnifiedTool, + on_delete=models.CASCADE, + related_name="authorizations", + ) + principal_type = models.CharField(max_length=24) + principal_id = models.PositiveBigIntegerField() + allowed = models.BooleanField(default=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_tool_authorizations", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["tool", "principal_type", "principal_id"], + name="unique_tool_authorization_principal", + ) + ] + + +class ToolCallStatus(models.TextChoices): + PENDING_CONFIRMATION = "pending_confirmation", "待确认" + RUNNING = "running", "执行中" + SUCCEEDED = "succeeded", "成功" + FAILED = "failed", "失败" + REJECTED = "rejected", "已拒绝" + + +class ToolCall(models.Model): + public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + tool = models.ForeignKey(UnifiedTool, on_delete=models.PROTECT, related_name="calls") + agent_run = models.ForeignKey( + "agents.AgentRun", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="tool_calls", + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="requested_tool_calls", + ) + status = models.CharField(max_length=32, choices=ToolCallStatus.choices) + input_payload = models.JSONField(default=dict) + output_payload = models.JSONField(null=True, blank=True) + authorization_snapshot = models.JSONField(default=dict) + risk_snapshot = models.JSONField(default=dict) + idempotency_key = models.CharField(max_length=160, blank=True) + error_code = models.CharField(max_length=80, blank=True) + error_message = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["tool", "idempotency_key"], + condition=models.Q(idempotency_key__gt=""), + name="unique_tool_call_idempotency_key", + ) + ] + + +class ToolConfirmation(models.Model): + call = models.OneToOneField( + ToolCall, + on_delete=models.CASCADE, + related_name="confirmation", + ) + status = models.CharField(max_length=24, default="pending") + reason = models.TextField(blank=True) + decided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="decided_tool_confirmations", + ) + decided_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/apps/tools/schemas.py b/backend/apps/tools/schemas.py new file mode 100644 index 0000000..3c77020 --- /dev/null +++ b/backend/apps/tools/schemas.py @@ -0,0 +1,99 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from ninja import Schema + + +class ToolProviderIn(Schema): + name: str + provider_type: str = "mcp_http" + base_url: str = "" + token: str = "" + enabled: bool = True + timeout_seconds: int = 30 + + +class ToolProviderOut(Schema): + id: int + name: str + provider_type: str + base_url: str + credential_set: bool + enabled: bool + timeout_seconds: int + created_at: datetime + updated_at: datetime + + +class ToolProviderUpdateIn(Schema): + name: str | None = None + base_url: str | None = None + token: str | None = None + enabled: bool | None = None + timeout_seconds: int | None = None + + +class UnifiedToolOut(Schema): + id: int + code: str + provider_id: int | None + external_name: str + name: str + description: str + input_schema: dict[str, Any] + risk_level: str + enabled: bool + synced_at: datetime | None + + +class UnifiedToolUpdateIn(Schema): + name: str | None = None + description: str | None = None + risk_level: str | None = None + enabled: bool | None = None + + +class ToolAuthorizationIn(Schema): + principal_type: str + principal_id: int + allowed: bool = True + + +class ToolAuthorizationOut(Schema): + id: int + tool_id: int + principal_type: str + principal_id: int + allowed: bool + + +class ToolCallIn(Schema): + tool_code: str + input: dict[str, Any] + agent_run_id: UUID | None = None + idempotency_key: str = "" + + +class ToolCallOut(Schema): + public_id: UUID + tool_code: str + requested_by_id: int | None + requested_by_name: str + status: str + input_payload: dict[str, Any] + output_payload: Any | None + authorization_snapshot: dict[str, Any] + risk_snapshot: dict[str, Any] + error_code: str + error_message: str + confirmation_status: str | None + confirmation_reason: str + created_at: datetime + started_at: datetime | None + finished_at: datetime | None + + +class ToolConfirmationIn(Schema): + decision: str + reason: str = "" diff --git a/backend/apps/tools/services.py b/backend/apps/tools/services.py new file mode 100644 index 0000000..35fd0dd --- /dev/null +++ b/backend/apps/tools/services.py @@ -0,0 +1,284 @@ +"""统一 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 diff --git a/backend/apps/tools/tests/__init__.py b/backend/apps/tools/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/tools/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/tools/tests/test_tool_runtime.py b/backend/apps/tools/tests/test_tool_runtime.py new file mode 100644 index 0000000..5968589 --- /dev/null +++ b/backend/apps/tools/tests/test_tool_runtime.py @@ -0,0 +1,354 @@ +import json + +import pytest +from django.contrib.auth import get_user_model +from django.db import transaction +from django.test import override_settings + +from apps.agents.models import AgentRun +from apps.agents.tests.helpers import create_agent +from apps.audit.models import OperationAuditLog +from apps.modelhub.crypto import encrypt_secret +from apps.tickets.models import Ticket +from apps.tools.models import ( + ToolAuthorization, + ToolCall, + ToolCallStatus, + ToolProvider, + ToolRiskLevel, + UnifiedTool, +) +from apps.tools.services import ( + ToolExecutionError, + decide_tool_confirmation, + request_tool_call, + sync_mcp_tools, +) +from apps.workitems.models import WorkItem, WorkItemType + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def read(self): + return json.dumps(self.payload).encode() + + +@pytest.mark.django_db +def test_http_mcp_sync_and_read_only_call_are_audited_and_idempotent( + django_capture_on_commit_callbacks, + monkeypatch, +): + responses = iter( + [ + { + "jsonrpc": "2.0", + "id": "ai-ticket-platform", + "result": { + "tools": [ + { + "name": "lookup_customer", + "description": "只读查询客户状态", + "inputSchema": { + "type": "object", + "properties": {"customer_id": {"type": "string"}}, + "required": ["customer_id"], + }, + } + ] + }, + }, + { + "jsonrpc": "2.0", + "id": "ai-ticket-platform", + "result": {"content": [{"type": "text", "text": "active"}]}, + }, + ] + ) + monkeypatch.setattr( + "apps.tools.services.urlopen", + lambda *args, **kwargs: FakeResponse(next(responses)), + ) + provider = ToolProvider.objects.create( + name="内部查询 MCP", + base_url="https://mcp.internal.example/rpc", + ) + tools = sync_mcp_tools(provider) + assert len(tools) == 1 + tool = tools[0] + assert tool.code == f"mcp.{provider.id}.lookup_customer" + actor = get_user_model().objects.create_superuser( + username="tool-admin", + email="tool-admin@example.com", + password="password", + ) + + with django_capture_on_commit_callbacks(execute=True): + first = request_tool_call( + tool, + {"customer_id": "C-001"}, + user=actor, + idempotency_key="lookup-C-001", + ) + first.refresh_from_db() + second = request_tool_call( + tool, + {"customer_id": "C-001"}, + user=actor, + idempotency_key="lookup-C-001", + ) + + assert first.status == ToolCallStatus.SUCCEEDED + assert first.output_payload["content"][0]["text"] == "active" + assert second.id == first.id + assert ToolCall.objects.count() == 1 + assert OperationAuditLog.objects.filter( + module="tools", + action="tool_call_finished", + target_id=str(first.public_id), + ).exists() + + +@pytest.mark.django_db +def test_high_risk_tool_requires_independent_confirmation( + django_capture_on_commit_callbacks, + monkeypatch, +): + monkeypatch.setattr( + "apps.tools.services.urlopen", + lambda *args, **kwargs: FakeResponse( + { + "jsonrpc": "2.0", + "id": "ai-ticket-platform", + "result": {"content": [{"type": "text", "text": "done"}]}, + } + ), + ) + provider = ToolProvider.objects.create( + name="变更 MCP", + base_url="https://mcp.internal.example/rpc", + ) + tool = UnifiedTool.objects.create( + code="mcp.change.restart", + provider=provider, + external_name="restart_service", + name="重启服务", + input_schema={"type": "object"}, + risk_level=ToolRiskLevel.HIGH_RISK, + ) + requester = get_user_model().objects.create_superuser( + username="tool-requester", + email="requester@example.com", + password="password", + ) + reviewer = get_user_model().objects.create_superuser( + username="tool-reviewer", + email="reviewer@example.com", + password="password", + ) + call = request_tool_call(tool, {}, user=requester) + assert call.status == ToolCallStatus.PENDING_CONFIRMATION + assert call.confirmation.status == "pending" + task = WorkItem.objects.get(task_type=WorkItemType.TOOL_CONFIRMATION) + assert task.status == "active" + + with pytest.raises(ToolExecutionError, match="不能由申请人自审"): + decide_tool_confirmation(call, reviewer=requester, decision="approve") + + with django_capture_on_commit_callbacks(execute=True): + decide_tool_confirmation( + call, + reviewer=reviewer, + decision="approve", + reason="已核对变更窗口", + ) + call.refresh_from_db() + call.confirmation.refresh_from_db() + assert call.status == ToolCallStatus.SUCCEEDED + assert call.confirmation.status == "approved" + assert call.confirmation.decided_by == reviewer + task.refresh_from_db() + assert task.status == "completed" + + +@pytest.mark.django_db +def test_tool_api_hides_credentials_and_blocks_unprivileged_invocation( + admin_client, + client, +): + response = admin_client.post( + "/api/tools/providers", + { + "name": "受控 MCP", + "provider_type": "mcp_http", + "base_url": "https://mcp.internal.example/rpc", + "token": "top-secret-token", + }, + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json()["credential_set"] is True + assert "token" not in response.json() + + user = get_user_model().objects.create_user( + username="unprivileged-tool-user", + password="password", + ) + client.force_login(user) + response = client.post( + "/api/tools/calls", + {"tool_code": "missing", "input": {}}, + content_type="application/json", + ) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_tool_governance_api_lists_grants_and_calls(admin_client): + provider = ToolProvider.objects.create( + name="治理 MCP", + base_url="https://mcp.internal.example/rpc", + ) + tool = UnifiedTool.objects.create( + code="mcp.governance.lookup", + provider=provider, + external_name="lookup", + name="治理查询", + input_schema={"type": "object"}, + ) + + response = admin_client.post( + f"/api/tools/{tool.id}/authorizations", + {"principal_type": "group", "principal_id": 7, "allowed": True}, + content_type="application/json", + ) + assert response.status_code == 200 + response = admin_client.get(f"/api/tools/{tool.id}/authorizations") + assert response.status_code == 200 + assert response.json()[0]["principal_type"] == "group" + + response = admin_client.patch( + f"/api/tools/providers/{provider.id}", + {"timeout_seconds": 45, "enabled": False}, + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json()["timeout_seconds"] == 45 + assert response.json()["enabled"] is False + + actor = get_user_model().objects.filter(is_superuser=True).first() + call = ToolCall.objects.create( + tool=tool, + requested_by=actor, + status=ToolCallStatus.SUCCEEDED, + input_payload={"query": "status"}, + output_payload={"status": "ok"}, + ) + response = admin_client.get("/api/tools/calls") + assert response.status_code == 200 + payload = response.json() + assert payload[0]["public_id"] == str(call.public_id) + assert payload[0]["requested_by_id"] == actor.id + + +@pytest.mark.django_db +@override_settings(INTERNAL_MCP_TOKEN="internal-ticket-token") +def test_internal_ticket_mcp_requires_bearer_and_queries_real_tickets(client): + Ticket.objects.create( + ticket_no="TK-MCP-001", + title="客户无法登录控制台", + description="登录后立即返回认证失败", + status="pending", + ) + rpc = { + "jsonrpc": "2.0", + "id": "gateway-test", + "method": "tools/call", + "params": { + "name": "search_tickets", + "arguments": {"query": "登录", "limit": 5}, + }, + } + unauthorized = client.post( + "/internal-mcp", + rpc, + content_type="application/json", + ) + assert unauthorized.status_code == 401 + + response = client.post( + "/internal-mcp", + rpc, + content_type="application/json", + HTTP_AUTHORIZATION="Bearer internal-ticket-token", + ) + assert response.status_code == 200 + structured = response.json()["result"]["structuredContent"] + assert structured["count"] == 1 + assert structured["tickets"][0]["ticket_no"] == "TK-MCP-001" + + +@pytest.mark.django_db(transaction=True) +@override_settings(INTERNAL_MCP_TOKEN="real-http-ticket-token") +def test_internal_ticket_mcp_real_http_agent_call_end_to_end(live_server): + ticket = Ticket.objects.create( + ticket_no="TK-MCP-E2E", + title="生产订单同步延迟", + description="订单同步任务积压,需要查询同类工单", + status="in_progress", + ) + actor = get_user_model().objects.create_superuser( + username="mcp-e2e-user", + email="mcp-e2e@example.com", + password="password", + ) + definition, version = create_agent(published=True, two_steps=False) + provider = ToolProvider.objects.create( + name="平台内部工单 MCP", + base_url=f"{live_server.url}/internal-mcp", + token_encrypted=encrypt_secret("real-http-ticket-token"), + ) + tools = sync_mcp_tools(provider) + assert len(tools) == 1 + tool = tools[0] + assert tool.external_name == "search_tickets" + version.allowed_actions = [f"tool.call:{tool.code}"] + version.save(update_fields=["allowed_actions"]) + run = AgentRun.objects.create( + definition=definition, + agent_version=version, + created_by=actor, + config_snapshot={ + "agent": {"allowed_actions": [f"tool.call:{tool.code}"]} + }, + ) + ToolAuthorization.objects.create( + tool=tool, + principal_type="agent", + principal_id=definition.id, + allowed=True, + ) + + with transaction.atomic(): + call = request_tool_call( + tool, + {"query": "订单同步", "status": "in_progress"}, + user=actor, + run=run, + idempotency_key="internal-ticket-mcp-e2e", + ) + call.refresh_from_db() + + assert call.status == ToolCallStatus.SUCCEEDED + structured = call.output_payload["structuredContent"] + assert structured["count"] == 1 + assert structured["tickets"][0]["id"] == ticket.id + assert call.authorization_snapshot["agent_grant"] is True + assert call.risk_snapshot["risk_level"] == ToolRiskLevel.READ_ONLY diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..25fd8ab --- /dev/null +++ b/frontend/src/components/ui/context-menu.tsx @@ -0,0 +1,261 @@ +import * as React from "react" +import { ContextMenu as ContextMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/frontend/src/features/agents/AgentDetailPage.tsx b/frontend/src/features/agents/AgentDetailPage.tsx index 9bd460a..0d209e7 100644 --- a/frontend/src/features/agents/AgentDetailPage.tsx +++ b/frontend/src/features/agents/AgentDetailPage.tsx @@ -13,6 +13,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { FieldGroup } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Textarea } from '@/components/ui/textarea' import { JsonEditor, parseJsonObject } from '@/features/ai-platform/components/JsonEditor' @@ -28,7 +29,12 @@ import { formatDateTime } from '@/lib/format' import { ensureAgentDraft, getAgent, publishAgent, rollbackAgent, startAgentRun, updateAgent, updateAgentDraft } from './api' import type { AgentDetail, AgentVersion } from './types' -type EditableStep = { code: string; skill_version_id: string; mapping: string } +type EditableStep = { + code: string + skill_version_id: string + model_deployment_id: string + mapping: string +} type SkillOption = SkillVersion & { definitionName: string } export function AgentDetailPage({ agentId }: { agentId: number }) { @@ -41,6 +47,7 @@ export function AgentDetailPage({ agentId }: { agentId: number }) { const [systemPrompt, setSystemPrompt] = React.useState('') const [temperature, setTemperature] = React.useState('') const [maxTokens, setMaxTokens] = React.useState('') + const [allowTicketResolve, setAllowTicketResolve] = React.useState(false) const [inputSchema, setInputSchema] = React.useState('{}') const [exampleInput, setExampleInput] = React.useState('{}') const [steps, setSteps] = React.useState([]) @@ -54,8 +61,14 @@ export function AgentDetailPage({ agentId }: { agentId: number }) { setModelId(String(version.model_deployment_id)); setSystemPrompt(version.system_prompt) setTemperature(version.temperature_override === null ? '' : String(version.temperature_override)) setMaxTokens(version.max_output_tokens_override === null ? '' : String(version.max_output_tokens_override)) + setAllowTicketResolve(version.allowed_actions.includes('ticket.resolve')) setInputSchema(JSON.stringify(version.input_schema, null, 2)); setExampleInput(JSON.stringify(version.example_input, null, 2)); setTestInput(JSON.stringify(version.example_input, null, 2)) - setSteps(version.steps.map((step) => ({ code: step.code, skill_version_id: String(step.skill_version_id), mapping: JSON.stringify(step.input_mapping, null, 2) }))) + setSteps(version.steps.map((step) => ({ + code: step.code, + skill_version_id: String(step.skill_version_id), + model_deployment_id: step.model_deployment_id === null ? '' : String(step.model_deployment_id), + mapping: JSON.stringify(step.input_mapping, null, 2), + }))) }, []) const load = React.useCallback(async () => { @@ -78,7 +91,7 @@ export function AgentDetailPage({ agentId }: { agentId: number }) { try { await Promise.all([ updateAgent(agentId, meta), - updateAgentDraft(agentId, { model_deployment_id: Number(modelId), system_prompt: systemPrompt, input_schema: parseJsonObject(inputSchema, 'Agent 输入 Schema'), example_input: parseJsonObject(exampleInput, '示例输入'), temperature_override: temperature === '' ? null : Number(temperature), max_output_tokens_override: maxTokens === '' ? null : Number(maxTokens), steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), input_mapping: parseJsonObject(step.mapping, `步骤 ${step.code} 输入映射`) })) }) + updateAgentDraft(agentId, { model_deployment_id: Number(modelId), system_prompt: systemPrompt, input_schema: parseJsonObject(inputSchema, 'Agent 输入 Schema'), example_input: parseJsonObject(exampleInput, '示例输入'), temperature_override: temperature === '' ? null : Number(temperature), max_output_tokens_override: maxTokens === '' ? null : Number(maxTokens), allowed_actions: allowTicketResolve ? ['ticket.resolve'] : [], steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), model_deployment_id: step.model_deployment_id === '' ? null : Number(step.model_deployment_id), input_mapping: parseJsonObject(step.mapping, `步骤 ${step.code} 输入映射`) })) }) ]) toast.success('Agent 草稿已保存'); await load() } catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') } @@ -92,11 +105,11 @@ export function AgentDetailPage({ agentId }: { agentId: number }) { if (!detail) return }>
return 当前 v{detail.active_version} : 尚未发布} actions={<>{draft ? <> : }} />}> 基础与模型步骤输入 Schema测试版本 - {draft ?
setMeta({ ...meta, name: e.target.value })} /> setMeta({ ...meta, description: e.target.value })} /> setTemperature(e.target.value)} /> setMaxTokens(e.target.value)} />