""" 外部工具 API 模块 符合 MCP-Server 调用规范 参考: http://gitee.ath.cx:3000/xiaohei/taiji-AI-PAD/src/branch/feature/chenchen/Docs/Agent-Manager外部工具接口规范.md 职责: - 接收 MCP-Server 的工具配置请求 - 生成 Pydantic AI 工具代码 - 存储工具配置和代码文件 - 支持工具的 CRUD 操作 - 支持带工具的 Agent 创建 """ import os import uuid import logging import requests from datetime import datetime from typing import Dict, List, Optional, Any from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field from agent_code_generator import agent_code_generator from tool_storage import tool_storage from gitee_manager import gitee_manager logger = logging.getLogger(__name__) # 创建路由 - 按照规范使用 /tools 前缀 router = APIRouter(prefix="/external-tools", tags=["External Tool API (MCP-Server 规范)"]) # ==================== 请求/响应模型 ==================== class AuthConfig(BaseModel): """认证配置""" type: str = Field(..., description="认证类型: api_key, bearer, basic") key: Optional[str] = Field(None, description="API Key 或 Bearer Token") token: Optional[str] = Field(None, description="Bearer Token(与 key 等效,兼容字段)") username: Optional[str] = Field(None, description="Basic Auth 用户名") password: Optional[str] = Field(None, description="Basic Auth 密码") in_location: Optional[str] = Field("header", alias="in", description="API Key 位置: header, query") name: Optional[str] = Field("X-API-Key", description="API Key 参数名") class Config: populate_by_name = True def get_token_or_key(self) -> Optional[str]: """获取 token 或 key(兼容两种字段名)""" return self.token or self.key class RetryConfig(BaseModel): """重试配置""" max_retries: int = Field(3, description="最大重试次数") retry_delay: int = Field(1, description="重试间隔(秒)") class GenerateToolRequest(BaseModel): """ 生成外部数据工具请求 符合接口规范文档 """ name: str = Field(..., min_length=1, max_length=100, description="工具名称(将用于生成 Python 函数名)") description: str = Field(..., description="工具描述(将作为工具的 docstring)") url: str = Field(..., description="API 端点 URL") method: str = Field(..., description="HTTP 方法:GET/POST/PUT/DELETE/PATCH") user_id: str = Field(..., description="用户 ID(UUID 格式)") tenant_id: Optional[str] = Field(None, description="租户 ID(UUID 格式)") headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头") auth: Optional[AuthConfig] = Field(None, description="认证配置") request_params: Optional[Dict] = Field(None, description="URL 查询参数定义(JSON Schema 格式)") request_body: Optional[Dict] = Field(None, description="请求体定义(JSON Schema 格式)") input_schema: Optional[Dict] = Field(None, description="输入参数定义(兼容字段,等同于 request_params)") response_mapping: Optional[Dict] = Field(None, description="响应字段映射") timeout: int = Field(30, description="超时时间(秒),默认 30") retry: Optional[RetryConfig] = Field(None, description="重试配置") use_ai: bool = Field(True, description="是否使用 AI 智能生成代码(默认开启,支持复杂场景如 URL 拼接)") api_key: Optional[str] = Field(None, description="LLM API Key(use_ai=true 时需要)") class SimpleGenerateToolRequest(BaseModel): """ 简化版工具生成请求 只需要三个核心字段:url、auth、request_body_schema 默认使用 AI 辅助生成 """ name: str = Field(..., min_length=1, max_length=100, description="工具名称") description: Optional[str] = Field(None, description="工具描述(可选,AI 会自动推断)") url: str = Field(..., description="API 端点 URL") method: str = Field("POST", description="HTTP 方法,默认 POST") user_id: str = Field("default", description="用户 ID") # 核心三要素 auth: Optional[AuthConfig] = Field(None, description="认证配置") request_body_schema: Optional[Dict] = Field(None, description="请求体 Schema (JSON Schema 格式)") request_params: Optional[Dict] = Field(None, description="URL 查询参数定义(可选)") headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头(可选)") # AI 生成相关 api_key: Optional[str] = Field(None, description="LLM API Key(可选,使用系统默认)") class Config: # 允许额外字段,让 AI 可以处理任意用户输入 extra = "allow" class UpdateToolRequest(BaseModel): """更新外部数据工具请求""" description: Optional[str] = Field(None, description="工具描述") url: Optional[str] = Field(None, description="API URL") method: Optional[str] = Field(None, description="HTTP 方法") headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头") auth: Optional[AuthConfig] = Field(None, description="认证配置") request_params: Optional[Dict] = Field(None, description="请求参数定义") request_body: Optional[Dict] = Field(None, description="请求体定义") response_mapping: Optional[Dict] = Field(None, description="响应字段映射") timeout: Optional[int] = Field(None, description="超时时间") class TestToolRequest(BaseModel): """测试工具连接请求""" test_params: Optional[Dict] = Field(None, description="测试参数") test_body: Optional[Dict] = Field(None, description="测试请求体") class ResourceConfig(BaseModel): """资源配置""" cpu_request: str = Field("100m", description="CPU 请求(如 100m, 500m)") cpu_limit: str = Field("500m", description="CPU 限制(如 500m, 1000m)") memory_request: str = Field("128Mi", description="内存请求(如 128Mi, 256Mi)") memory_limit: str = Field("512Mi", description="内存限制(如 512Mi, 1Gi)") class CreateAgentWithToolsRequest(BaseModel): """创建带有外部工具的 Agent 请求""" name: str = Field(..., min_length=1, max_length=63, description="Agent 名称(符合 K8s 命名规范)") template: str = Field(..., description="Agent 模板名称") tool_refs: List[str] = Field(default=[], description="外部数据工具标识列表") config: Optional[Dict] = Field(default_factory=dict, description="其他配置") env: Optional[Dict[str, str]] = Field(default_factory=dict, description="环境变量") # 资源配置 - 参考原有模板创建方式 cpu_request: Optional[str] = Field("100m", description="CPU 请求(如 100m, 500m)") cpu_limit: Optional[str] = Field("500m", description="CPU 限制(如 500m, 1000m)") memory_request: Optional[str] = Field("128Mi", description="内存请求(如 128Mi, 256Mi)") memory_limit: Optional[str] = Field("512Mi", description="内存限制(如 512Mi, 1Gi)") replicas: Optional[int] = Field(1, ge=1, le=10, description="副本数量") # ==================== 工具 API 接口 ==================== @router.post("/generate") async def generate_tool(request: GenerateToolRequest): """ 1️⃣ 生成外部数据工具 MCP-Server 调用此接口,Agent Manager 需要: 1. 验证配置格式 2. 根据配置生成 Pydantic AI 工具代码文件 3. 存储工具代码文件和完整配置(包含敏感信息如 API Key) 4. 返回唯一的 tool_ref_id 供后续引用 """ try: # 验证 HTTP 方法 valid_methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] if request.method.upper() not in valid_methods: return { "success": False, "error": "invalid_method", "message": f"无效的 HTTP 方法。支持: {', '.join(valid_methods)}" } # 验证 URL 格式 if not request.url.startswith(("http://", "https://")): return { "success": False, "error": "invalid_url", "message": "URL 必须以 http:// 或 https:// 开头" } # 验证认证配置 if request.auth: valid_auth_types = ["api_key", "bearer", "basic"] if request.auth.type not in valid_auth_types: return { "success": False, "error": "invalid_auth", "message": f"无效的认证类型。支持: {', '.join(valid_auth_types)}" } # 生成唯一 tool_ref_id tool_ref_id = f"tool-{request.name.lower().replace(' ', '-')}-{uuid.uuid4().hex[:8]}" # 合并 request_params 和 input_schema(兼容两种字段名) merged_params = request.request_params or request.input_schema # 处理认证配置 - 兼容 token 和 key 字段 auth_config = None if request.auth: auth_config = request.auth.model_dump(by_alias=True) # 兼容 token 字段:如果用户使用 token,将其映射到 key if auth_config.get("token") and not auth_config.get("key"): auth_config["key"] = auth_config["token"] # 构建工具配置 tool_config = { "name": request.name, "description": request.description, "url": request.url, "method": request.method.upper(), "headers": request.headers, "auth": auth_config, "request_params": merged_params, "input_schema": merged_params, # 保留两种格式供 AI 理解 "request_body": request.request_body, "response_mapping": request.response_mapping, "timeout": request.timeout, "retry": request.retry.model_dump() if request.retry else None } # 生成 Pydantic AI 工具代码 if request.use_ai: # 使用 AI 智能生成(支持复杂场景) import asyncio import concurrent.futures logger.info(f"🤖 使用 AI 生成工具代码: {request.name}") # 在新线程中运行异步代码 def run_async(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: return loop.run_until_complete( agent_code_generator.generate_tool_code_with_ai(tool_config, request.api_key) ) finally: loop.close() with concurrent.futures.ThreadPoolExecutor() as executor: future = executor.submit(run_async) tool_code = future.result(timeout=90) else: # 使用模板生成 tool_code = agent_code_generator.generate_tool_code(tool_config) # 存储工具配置和代码 save_result = tool_storage.save_tool( tool_ref_id=tool_ref_id, name=request.name, description=request.description, config=tool_config, code=tool_code, user_id=request.user_id, tenant_id=request.tenant_id ) if not save_result.get("success"): return { "success": False, "error": "generation_failed", "message": save_result.get("error", "工具保存失败") } logger.info(f"✅ 工具生成成功: {tool_ref_id} (用户: {request.user_id})") return { "success": True, "data": { "tool_ref_id": tool_ref_id, "name": request.name, "description": request.description, "created_at": datetime.utcnow().isoformat() }, "message": "工具生成成功" } except Exception as e: logger.error(f"工具生成失败: {e}") import traceback logger.error(traceback.format_exc()) return { "success": False, "error": "generation_failed", "message": str(e) } @router.post("/generate-simple") async def generate_tool_simple(request: SimpleGenerateToolRequest): """ 🚀 简化版工具生成接口 - 默认使用 AI 辅助 只需要提供三个核心字段: 1. url - API 端点 URL 2. auth - 认证配置(bearer/api_key/basic) 3. request_body_schema - 请求体 Schema AI 会智能理解您的配置并生成高质量的 Pydantic AI 工具代码。 示例请求: ```json { "name": "jina_reader", "url": "https://r.jina.ai/", "method": "POST", "user_id": "test-user", "auth": { "type": "bearer", "token": "your-token-here" }, "request_body_schema": { "type": "object", "properties": { "url": {"type": "string", "description": "要爬取的网页URL"} }, "required": ["url"] } } ``` """ try: # 验证 URL 格式 if not request.url.startswith(("http://", "https://")): return { "success": False, "error": "invalid_url", "message": "URL 必须以 http:// 或 https:// 开头" } # 生成唯一 tool_ref_id tool_ref_id = f"tool-{request.name.lower().replace(' ', '-')}-{uuid.uuid4().hex[:8]}" # 处理认证配置 - 兼容 token 和 key 字段 auth_config = None if request.auth: auth_config = request.auth.model_dump(by_alias=True) # 兼容 token 字段:如果用户使用 token,将其映射到 key if auth_config.get("token") and not auth_config.get("key"): auth_config["key"] = auth_config["token"] # 自动推断描述(如果未提供) description = request.description if not description: description = f"调用 {request.name} API" if request.request_body_schema: props = request.request_body_schema.get("properties", {}) if props: param_names = list(props.keys())[:3] description += f",参数: {', '.join(param_names)}" # 构建完整的工具配置 - 保留用户原始输入供 AI 理解 tool_config = { "name": request.name, "description": description, "url": request.url, "method": request.method.upper(), "headers": request.headers, "auth": auth_config, # 支持两种参数格式 "request_body": request.request_body_schema, "request_params": request.request_params, "input_schema": request.request_body_schema or request.request_params, "timeout": 30, # 保存原始请求供 AI 参考(可能包含额外字段) "_original_request": request.model_dump(exclude_unset=False) } # 🤖 默认使用 AI 生成(更智能、更灵活) import asyncio import concurrent.futures logger.info(f"🤖 [简化模式] 使用 AI 生成工具代码: {request.name}") def run_async(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: return loop.run_until_complete( agent_code_generator.generate_tool_code_with_ai(tool_config, request.api_key) ) finally: loop.close() with concurrent.futures.ThreadPoolExecutor() as executor: future = executor.submit(run_async) tool_code = future.result(timeout=90) # 存储工具配置和代码 save_result = tool_storage.save_tool( tool_ref_id=tool_ref_id, name=request.name, description=description, config=tool_config, code=tool_code, user_id=request.user_id, tenant_id=None ) if not save_result.get("success"): return { "success": False, "error": "generation_failed", "message": save_result.get("error", "工具保存失败") } logger.info(f"✅ [简化模式] 工具生成成功: {tool_ref_id}") return { "success": True, "data": { "tool_ref_id": tool_ref_id, "name": request.name, "description": description, "url": request.url, "method": request.method.upper(), "has_auth": bool(request.auth), "created_at": datetime.utcnow().isoformat() }, "message": "工具生成成功 (AI 辅助)" } except Exception as e: logger.error(f"[简化模式] 工具生成失败: {e}") import traceback logger.error(traceback.format_exc()) return { "success": False, "error": "generation_failed", "message": str(e) } @router.put("/{tool_ref_id}") async def update_tool(tool_ref_id: str, request: UpdateToolRequest): """ 2️⃣ 更新外部数据工具 更新已存在的工具配置,重新生成代码 """ try: # 检查工具是否存在 existing_tool = tool_storage.get_tool(tool_ref_id) if not existing_tool: raise HTTPException( status_code=404, detail={"success": False, "error": "tool_not_found", "message": "工具不存在"} ) # 构建更新内容 updates = {} if request.description is not None: updates["description"] = request.description if request.url is not None: updates["url"] = request.url if request.method is not None: # 验证 HTTP 方法 valid_methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] if request.method.upper() not in valid_methods: return { "success": False, "error": "invalid_method", "message": f"无效的 HTTP 方法。支持: {', '.join(valid_methods)}" } updates["method"] = request.method.upper() if request.headers is not None: updates["headers"] = request.headers if request.auth is not None: updates["auth"] = request.auth.model_dump(by_alias=True) if request.request_params is not None: updates["request_params"] = request.request_params if request.request_body is not None: updates["request_body"] = request.request_body if request.response_mapping is not None: updates["response_mapping"] = request.response_mapping if request.timeout is not None: updates["timeout"] = request.timeout # 更新工具配置 updated_tool = tool_storage.update_tool(tool_ref_id, updates) if not updated_tool: return { "success": False, "error": "update_failed", "message": "工具更新失败" } # 重新生成代码 tool_code = agent_code_generator.generate_tool_code(updated_tool["config"]) # 更新代码文件 tool_storage.save_tool( tool_ref_id=tool_ref_id, name=updated_tool["name"], description=updated_tool.get("description", ""), config=updated_tool["config"], code=tool_code, user_id=updated_tool["user_id"], tenant_id=updated_tool.get("tenant_id") ) logger.info(f"✅ 工具更新成功: {tool_ref_id}") return { "success": True, "data": { "tool_ref_id": tool_ref_id, "name": updated_tool["name"], "updated_at": updated_tool.get("updated_at", datetime.utcnow().isoformat()) }, "message": "工具更新成功" } except HTTPException: raise except Exception as e: logger.error(f"工具更新失败: {e}") return { "success": False, "error": "update_failed", "message": str(e) } @router.delete("/{tool_ref_id}") async def delete_tool(tool_ref_id: str): """ 3️⃣ 删除外部数据工具 删除工具配置和代码文件 """ try: # 检查工具是否存在 existing_tool = tool_storage.get_tool(tool_ref_id) if not existing_tool: raise HTTPException( status_code=404, detail={"success": False, "error": "tool_not_found", "message": "工具不存在"} ) # 检查工具是否正在被使用 if tool_storage.check_tool_in_use(tool_ref_id): return { "success": False, "error": "tool_in_use", "message": "工具正在被 Agent 使用,无法删除" } # 删除工具 tool_storage.delete_tool(tool_ref_id) logger.info(f"✅ 工具删除成功: {tool_ref_id}") return { "success": True, "data": { "tool_ref_id": tool_ref_id }, "message": "工具删除成功" } except HTTPException: raise except Exception as e: logger.error(f"工具删除失败: {e}") return { "success": False, "error": "delete_failed", "message": str(e) } @router.post("/{tool_ref_id}/test") async def test_tool(tool_ref_id: str, request: TestToolRequest = None): """ 4️⃣ 测试工具连接 使用存储的配置发送测试请求,验证 API 连通性 """ try: # 获取工具配置 tool = tool_storage.get_tool(tool_ref_id) if not tool: raise HTTPException( status_code=404, detail={"success": False, "error": "tool_not_found", "message": "工具不存在"} ) config = tool.get("config", {}) # 构建请求 url = config.get("url", "") method = config.get("method", "GET") timeout = config.get("timeout", 30) headers = config.get("headers", {}) or {} params = (request.test_params if request else None) or {} body = (request.test_body if request else None) or None # 处理认证 auth = config.get("auth") if auth: auth_type = auth.get("type", "") if auth_type == "api_key": location = auth.get("in", "header") key_name = auth.get("name", "X-API-Key") # 兼容 token 和 key 字段 key_value = auth.get("token") or auth.get("key", "") if location == "header": headers[key_name] = key_value elif location == "query": params[key_name] = key_value elif auth_type == "bearer": # 兼容 token 和 key 字段 token_value = auth.get("token") or auth.get("key", "") headers["Authorization"] = f"Bearer {token_value}" elif auth_type == "basic": import base64 credentials = base64.b64encode( f"{auth.get('username', '')}:{auth.get('password', '')}".encode() ).decode() headers["Authorization"] = f"Basic {credentials}" # 发送测试请求 start_time = datetime.utcnow() response = requests.request( method=method, url=url, headers=headers, params=params if method.upper() == "GET" else None, json=body if method.upper() != "GET" else None, timeout=timeout ) elapsed_ms = (datetime.utcnow() - start_time).total_seconds() * 1000 # 构建响应 result = { "status_code": response.status_code, "response_time_ms": round(elapsed_ms, 2), "content_type": response.headers.get("Content-Type"), "headers": dict(response.headers) } # 响应预览(截断) if response.text: result["response_preview"] = response.text[:500] if len(response.text) > 500: result["response_preview"] += "... (truncated)" # 判断成功 is_success = 200 <= response.status_code < 300 return { "success": is_success, "data": result, "message": "连接测试成功" if is_success else f"连接测试失败 (HTTP {response.status_code})" } except requests.exceptions.Timeout: return { "success": False, "error": "timeout", "message": "连接超时" } except requests.exceptions.ConnectionError: return { "success": False, "error": "connection_error", "message": "无法连接到目标服务" } except HTTPException: raise except Exception as e: logger.error(f"工具测试失败: {e}") return { "success": False, "error": "test_failed", "message": str(e) } @router.get("/{tool_ref_id}") async def get_tool(tool_ref_id: str): """ 获取工具详情 返回工具的完整配置信息(不含敏感信息) """ try: tool = tool_storage.get_tool(tool_ref_id) if not tool: raise HTTPException( status_code=404, detail={"success": False, "error": "tool_not_found", "message": "工具不存在"} ) # 脱敏处理 safe_tool = { "tool_ref_id": tool["tool_ref_id"], "name": tool["name"], "description": tool.get("description"), "url": tool["config"].get("url"), "method": tool["config"].get("method"), "status": tool.get("status", "created"), "created_at": tool.get("created_at"), "updated_at": tool.get("updated_at"), "user_id": tool.get("user_id"), "tenant_id": tool.get("tenant_id"), "has_auth": bool(tool["config"].get("auth")), "used_by_agents": tool.get("used_by_agents", []) } return { "success": True, "data": safe_tool } except HTTPException: raise except Exception as e: logger.error(f"获取工具失败: {e}") return { "success": False, "error": "get_failed", "message": str(e) } @router.get("/") async def list_tools( user_id: Optional[str] = Query(None, description="用户 ID 过滤"), tenant_id: Optional[str] = Query(None, description="租户 ID 过滤") ): """ 列出所有工具 支持按用户和租户过滤 """ try: tools = tool_storage.list_tools(user_id=user_id, tenant_id=tenant_id) # 简化输出 tool_list = [ { "tool_ref_id": t["tool_ref_id"], "name": t["name"], "description": t.get("description"), "status": t.get("status", "created"), "created_at": t.get("created_at") } for t in tools ] return { "success": True, "data": { "tools": tool_list, "count": len(tool_list) } } except Exception as e: logger.error(f"列出工具失败: {e}") return { "success": False, "error": "list_failed", "message": str(e) } @router.get("/{tool_ref_id}/code") async def get_tool_code(tool_ref_id: str): """ 获取工具生成的代码 返回 Pydantic AI 工具代码 """ try: code = tool_storage.get_tool_code(tool_ref_id) if not code: raise HTTPException( status_code=404, detail={"success": False, "error": "tool_not_found", "message": "工具不存在"} ) return { "success": True, "data": { "tool_ref_id": tool_ref_id, "code": code } } except HTTPException: raise except Exception as e: logger.error(f"获取工具代码失败: {e}") return { "success": False, "error": "get_code_failed", "message": str(e) } # ==================== 工具集成到 Agent ==================== @router.post("/agents/create-with-tools") async def create_agent_with_tools(request: CreateAgentWithToolsRequest): """ 5️⃣ 创建带有外部工具的 Agent 这是扩展的 Agent 创建接口,支持 tool_refs 字段。 当传递 tool_refs 时,Agent Manager 需要: 1. 加载对应的工具代码文件 2. 将工具集成到 Agent 中 3. 生成完整项目并部署到 AKS """ try: # 验证工具引用 tools = [] missing_tools = [] for ref in request.tool_refs: tool = tool_storage.get_tool(ref) if tool: tools.append(tool) else: missing_tools.append(ref) if missing_tools: return { "success": False, "error": "tool_not_found", "message": f"以下工具不存在: {', '.join(missing_tools)}" } # 转换工具配置为代码生成器需要的格式 tools_config = [] for tool in tools: config = tool.get("config", {}) # 获取 AI 生成的工具代码 tool_code = tool_storage.get_tool_code(tool.get("tool_ref_id", "")) tools_config.append({ "name": tool["name"], "description": tool.get("description", ""), "url": config.get("url"), "method": config.get("method", "GET"), "auth": config.get("auth"), "request_params": config.get("request_params"), "request_body": config.get("request_body"), "timeout": config.get("timeout", 30), "generated_code": tool_code # 传递 AI 生成的代码 }) # 生成唯一后缀,确保不同用户创建同名 Agent 不会冲突 unique_suffix = uuid.uuid4().hex[:6] base_name = request.name.lower().replace("_", "-").replace(" ", "-") # k8s_name 带唯一后缀,避免域名/namespace 冲突 k8s_name = f"{base_name}-{unique_suffix}" repo_name = f"agent-{k8s_name}" agent_ref_id = f"agent-{repo_name}" # 生成完整项目文件(传递资源配置) # 使用 k8s_name 作为 agent_name,确保 CI/CD 中创建的 DNS/namespace 与 API 返回一致 project_files = agent_code_generator.generate_full_project( agent_name=k8s_name, description=f"Agent with {len(tools)} external tools", tools_config=tools_config, auto_deploy=True, cpu_request=request.cpu_request or "100m", cpu_limit=request.cpu_limit or "500m", memory_request=request.memory_request or "128Mi", memory_limit=request.memory_limit or "512Mi", replicas=request.replicas or 1 ) # 创建 Gitee 仓库 repo_result = gitee_manager.create_repository( repo_name=repo_name, description=f"{request.name} - Agent with external tools", private=False ) if not repo_result.get("success"): return { "success": False, "error": "repo_creation_failed", "message": repo_result.get("error", "仓库创建失败") } # 获取仓库所有者 repo_owner = repo_result.get("owner", gitee_manager.gitee_username) # ⚠️ 重要:先设置 CI/CD Secrets,再推送文件 # 因为推送文件会触发 CI/CD,必须确保 secrets 已经就绪 cicd_secrets = { "ACR_LOGIN_SERVER": "agnettaiji.azurecr.io", "ACR_USERNAME": "agnettaiji", "ACR_PASSWORD": "hDpX5t34N5ZmnKdtqyjYL5co/SnXJrmD20CRpGpWaG+ACRCw2wGM", "AZ_CLIENT_ID": "f2dd1cb2-02f6-4efb-bc72-d148f6e01545", "AZ_CLIENT_SECRET": "UVU8Q~Hcrf5KeLi2RvUXB2rcuKFEjRCCrf_JrbwA", "AZ_TENANT_ID": "263c3ff6-1be5-4141-8308-b188464fb297", "AZ_SUBSCRIPTION_ID": "45d7a360-af09-40fc-9afc-56dc475245ec", "AZ_RG": "taiji-ai-pda", "AZ_AKS": "taiji-ai-pda", "AZURE_DNS_ZONE": "taijiagnet.com", "AZURE_DNS_RG": "taiji-Ai-v0" } logger.info(f"📝 设置 CI/CD Secrets(共 {len(cicd_secrets)} 个)...") secrets_result = gitee_manager.set_repo_secrets( repo_name=repo_name, secrets=cicd_secrets, owner=repo_owner ) if not secrets_result.get("success"): logger.warning(f"⚠️ 部分 Secrets 设置可能失败: {secrets_result}") # 等待一小段时间确保 secrets 生效 import time time.sleep(1) # 推送文件(这会触发 CI/CD) logger.info(f"📤 推送项目文件...") push_result = gitee_manager.push_files( repo_name=repo_name, files=project_files, commit_message=f"Initial commit: {request.name} with {len(tools)} tools", owner=repo_owner ) # 标记工具被使用 for ref in request.tool_refs: tool_storage.mark_tool_in_use(ref, request.name) # 计算域名和 namespace(k8s_name 已在上面定义,带唯一后缀) expected_domain = f"{k8s_name}.taijiagnet.com" namespace = f"agent-{k8s_name}" # 存储 Agent 信息以便后续查询 AGENT_REFS[agent_ref_id] = { "agent_ref_id": agent_ref_id, "name": request.name, # 原始名称(用户输入) "display_name": request.name, # 显示名称 "k8s_name": k8s_name, # K8s 名称(带唯一后缀,用于域名/namespace) "repo_name": repo_name, "repo_url": repo_result.get("html_url"), "repo_owner": repo_owner, "namespace": namespace, "domain": expected_domain, "image_name": f"agnettaiji.azurecr.io/ai-agents/{repo_name}:latest", "tools": [t["name"] for t in tools], "tool_refs": request.tool_refs, "user_id": request.config.get("user_id") if request.config else None, "created_at": datetime.utcnow().isoformat(), "status": "building" } logger.info(f"✅ Agent 创建成功: {request.name} (工具: {len(tools)} 个)") logger.info(f" Agent Ref ID: {agent_ref_id}") logger.info(f" 查询构建状态: GET /external-tools/agents/{agent_ref_id}/build-status") return { "success": True, "name": request.name, "agent_ref_id": agent_ref_id, "namespace": namespace, "status": "Building", "created_at": datetime.utcnow().isoformat(), "template": request.template, "service_port": 8000, "access_info": { "domain": expected_domain, "domain_url": f"http://{expected_domain}", "ip_url": "pending" }, "resources": { "cpu_request": request.cpu_request or "100m", "cpu_limit": request.cpu_limit or "500m", "memory_request": request.memory_request or "128Mi", "memory_limit": request.memory_limit or "512Mi", "replicas": request.replicas or 1 }, "tools_attached": len(tools), "repo_url": repo_result.get("html_url"), "image_name": f"agnettaiji.azurecr.io/ai-agents/{repo_name}:latest", "status_query_url": f"/external-tools/agents/{agent_ref_id}/build-status", "deployment_info_url": f"/external-tools/agents/{agent_ref_id}/deployment-info" } except Exception as e: logger.error(f"创建带工具的 Agent 失败: {e}") import traceback logger.error(traceback.format_exc()) return { "success": False, "error": "deployment_failed", "message": str(e) } @router.get("/{tool_ref_id}/preview-code") async def preview_generated_code(tool_ref_id: str): """ 预览工具生成的 Pydantic AI 代码 用于调试和验证代码生成是否正确 """ try: tool = tool_storage.get_tool(tool_ref_id) if not tool: raise HTTPException( status_code=404, detail={"success": False, "error": "tool_not_found", "message": "工具不存在"} ) code = tool_storage.get_tool_code(tool_ref_id) return { "success": True, "data": { "tool_ref_id": tool_ref_id, "name": tool["name"], "generated_code": code, "code_preview": code[:1000] if code else None } } except HTTPException: raise except Exception as e: logger.error(f"预览代码失败: {e}") return { "success": False, "error": "preview_failed", "message": str(e) } # ==================== Agent 状态查询 ==================== # 内存存储 Agent 引用(用于查询构建状态) AGENT_REFS: Dict[str, Dict] = {} @router.get("/agents/{agent_ref_id}/build-status") async def get_agent_build_status(agent_ref_id: str): """ 查询 Agent CI/CD 构建状态 返回: - Gitee Action 运行状态 - ACR 镜像是否已构建 - 整体构建状态 """ try: # 检查是否有存储的 agent 信息 agent_info = AGENT_REFS.get(agent_ref_id) if not agent_info: # 尝试从 repo_name 推断 if agent_ref_id.startswith("agent-"): repo_name = agent_ref_id.replace("agent-", "", 1) else: return { "success": False, "error": "agent_not_found", "message": f"Agent {agent_ref_id} 不存在或信息已丢失" } else: repo_name = agent_info.get("repo_name", agent_ref_id.replace("agent-", "", 1)) # 查询 Gitee Action 状态 action_status = gitee_manager.get_action_status(repo_name) # 查询 ACR 镜像状态 image_name = f"agnettaiji.azurecr.io/ai-agents/{repo_name}:latest" acr_status = _check_acr_image(image_name) # 判断整体状态 overall_status = "unknown" if acr_status.get("exists"): overall_status = "ready" elif action_status.get("conclusion") == "failure": overall_status = "build_failed" elif action_status.get("status") == "in_progress": overall_status = "building" elif action_status.get("status") == "completed" and action_status.get("conclusion") == "success": overall_status = "deployed" elif action_status.get("status") == "no_runs": overall_status = "pending" # 计算域名 - 优先使用存储的 k8s_name if agent_info and agent_info.get("k8s_name"): k8s_name = agent_info.get("k8s_name") else: # 无法从 repo_name 准确推断,使用 agent 名称 agent_name = agent_info.get("name") if agent_info else None if agent_name: k8s_name = agent_name.lower().replace("_", "-").replace(" ", "-") else: k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-") expected_domain = f"{k8s_name}.taijiagnet.com" namespace = f"agent-{k8s_name}" return { "success": True, "data": { "agent_ref_id": agent_ref_id, "repo_name": repo_name, "overall_status": overall_status, "gitee_action": { "status": action_status.get("status"), "conclusion": action_status.get("conclusion"), "run_id": action_status.get("run_id"), "html_url": action_status.get("html_url"), "message": action_status.get("message") }, "acr_image": { "image_name": image_name, "exists": acr_status.get("exists", False), "tags": acr_status.get("all_tags", []) }, "access_info": { "expected_domain": expected_domain, "expected_url": f"http://{expected_domain}", "expected_namespace": namespace } } } except Exception as e: logger.error(f"查询构建状态失败: {e}") return { "success": False, "error": "query_failed", "message": str(e) } @router.get("/agents/{agent_ref_id}/deployment-info") async def get_agent_deployment_info(agent_ref_id: str): """ 获取 Agent 部署后的详细信息 返回: - K8s 部署状态 - 服务端点信息 - DNS 域名信息 - 访问 URL """ try: # 检查是否有存储的 agent 信息 agent_info = AGENT_REFS.get(agent_ref_id) # 从 agent_ref_id 推断 repo_name if agent_info: repo_name = agent_info.get("repo_name") k8s_name = agent_info.get("k8s_name") namespace = agent_info.get("namespace") else: if agent_ref_id.startswith("agent-"): repo_name = agent_ref_id.replace("agent-", "", 1) else: repo_name = agent_ref_id k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-") namespace = f"agent-{k8s_name}" # 导入 K8sManager 查询实际状态 from k8s_manager import K8sManager deployment_info = { "agent_ref_id": agent_ref_id, "repo_name": repo_name, "namespace": namespace, "k8s_status": {}, "access_info": {}, "image_info": {} } try: k8s_mgr = K8sManager(namespace=namespace) # 查询 namespace 是否存在 try: ns = k8s_mgr.v1.read_namespace(name=namespace) deployment_info["k8s_status"]["namespace_exists"] = True deployment_info["k8s_status"]["namespace_status"] = ns.status.phase except Exception: deployment_info["k8s_status"]["namespace_exists"] = False deployment_info["k8s_status"]["status"] = "not_deployed" return { "success": True, "data": deployment_info, "message": "Agent 尚未部署到 K8s" } # 查询 Pod 状态 try: pods = k8s_mgr.v1.list_namespaced_pod(namespace=namespace) if pods.items: pod = pods.items[0] deployment_info["k8s_status"]["pod_name"] = pod.metadata.name deployment_info["k8s_status"]["pod_status"] = pod.status.phase deployment_info["k8s_status"]["pod_ip"] = pod.status.pod_ip deployment_info["k8s_status"]["node_name"] = pod.spec.node_name deployment_info["k8s_status"]["status"] = "running" if pod.status.phase == "Running" else pod.status.phase.lower() except Exception as e: deployment_info["k8s_status"]["pod_error"] = str(e) # 查询 Service 状态 try: services = k8s_mgr.v1.list_namespaced_service(namespace=namespace) for svc in services.items: if svc.spec.type == "LoadBalancer": deployment_info["access_info"]["service_name"] = svc.metadata.name deployment_info["access_info"]["cluster_ip"] = svc.spec.cluster_ip # 获取外网 IP if svc.status.load_balancer.ingress: external_ip = svc.status.load_balancer.ingress[0].ip deployment_info["access_info"]["external_ip"] = external_ip deployment_info["access_info"]["ip_url"] = f"http://{external_ip}" except Exception as e: deployment_info["access_info"]["service_error"] = str(e) # DNS 域名信息 expected_domain = f"{k8s_name}.taijiagnet.com" deployment_info["access_info"]["domain"] = expected_domain deployment_info["access_info"]["domain_url"] = f"http://{expected_domain}" deployment_info["access_info"]["recommended_url"] = f"http://{expected_domain}" # 镜像信息 deployment_info["image_info"] = { "image_name": f"agnettaiji.azurecr.io/ai-agents/{repo_name}:latest", "registry": "agnettaiji.azurecr.io" } except Exception as e: logger.error(f"查询 K8s 状态失败: {e}") deployment_info["k8s_status"]["error"] = str(e) return { "success": True, "data": deployment_info } except Exception as e: logger.error(f"获取部署信息失败: {e}") return { "success": False, "error": "query_failed", "message": str(e) } def _check_acr_image(image_name: str) -> Dict[str, Any]: """检查 ACR 镜像是否存在""" try: import subprocess # 解析镜像名称 parts = image_name.split("/") if len(parts) < 3: return {"exists": False, "error": "Invalid image name"} registry = parts[0].replace(".azurecr.io", "") repo = "/".join(parts[1:]).split(":")[0] tag = parts[-1].split(":")[-1] if ":" in parts[-1] else "latest" # 使用 az acr 命令检查 result = subprocess.run( ["az", "acr", "repository", "show-tags", "--name", registry, "--repository", repo, "--output", "json"], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: import json tags = json.loads(result.stdout) if tag in tags: return { "exists": True, "tag": tag, "all_tags": tags } return {"exists": False} except Exception as e: logger.debug(f"检查 ACR 镜像失败: {e}") return {"exists": False, "error": str(e)}