From 8cb166fb58a513a1cc025947586ed9e4bdb20255 Mon Sep 17 00:00:00 2001 From: zhanggangyong Date: Thu, 29 Jan 2026 12:00:16 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=20API=20=E5=8F=8A=E5=9B=9E=E8=B0=83=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=88=E8=AE=A1=E8=B4=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要更新: - 新增 external_tool_api.py: 外部工具管理 API - 新增 tool_storage.py: 工具存储管理器 - 新增回调功能用于计费 (agent_callback_utils) - 支持多工具创建 Agent - 新增 CI/CD 构建状态查询 API - 新增部署信息查询 API - 更新文档 (EXTERNAL_TOOL_API.md v2.0) - 更新 Dockerfile 添加新模块 - 更新 app.py 集成外部工具路由 --- Dockerfile | 5 + agent_code_generator.py | 556 ++++++++- app.py | 41 + docs/DNS_ISSUE_FIX_REPORT.md | 223 ++++ docs/DYNAMIC_AGENT_GENERATOR_API.md | 627 ++++------ docs/EXTERNAL_TOOL_API.md | 741 ++++++++++++ external_tool_api.py | 1025 +++++++++++++++++ k8s/README.md | 414 +++++++ k8s/agent-manager-deployment.yaml | 2 +- .../redeploy-agent-manager-with-kubeconfig.sh | 79 ++ scripts/setup_dns_permissions.sh | 192 +++ tool_storage.py | 314 +++++ 12 files changed, 3777 insertions(+), 442 deletions(-) create mode 100644 docs/DNS_ISSUE_FIX_REPORT.md create mode 100644 docs/EXTERNAL_TOOL_API.md create mode 100644 external_tool_api.py create mode 100644 k8s/README.md create mode 100755 scripts/redeploy-agent-manager-with-kubeconfig.sh create mode 100755 scripts/setup_dns_permissions.sh create mode 100644 tool_storage.py diff --git a/Dockerfile b/Dockerfile index fc5c713..91c52ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,13 @@ COPY template_manager.py . COPY gitee_manager.py . COPY agent_code_generator.py . COPY tool_generator_api.py . +COPY external_tool_api.py . +COPY tool_storage.py . COPY agent_manager/ ./agent_manager/ +# 创建工具存储目录 +RUN mkdir -p /app/tool_storage + # 安装依赖 RUN pip install --no-cache-dir -r requirements.txt diff --git a/agent_code_generator.py b/agent_code_generator.py index 8e669ce..d507d57 100644 --- a/agent_code_generator.py +++ b/agent_code_generator.py @@ -101,6 +101,135 @@ class AgentCodeGenerator: } return type_map.get(json_type, "Any") + def generate_tool_code(self, tool_config: dict) -> str: + """ + 根据工具配置生成 Pydantic 工具代码 + + Args: + tool_config: 包含 name, url, method, auth, request_params 等 + + Returns: + 生成的 Python 代码字符串 + """ + name = tool_config.get("name", "custom_tool") + func_name = self._convert_name_to_python(name) + desc = tool_config.get("description", "") + url = tool_config.get("url", "") + method = tool_config.get("method", "GET").upper() + auth = tool_config.get("auth", {}) + request_params = tool_config.get("request_params", {}) + request_body = tool_config.get("request_body", {}) + timeout = tool_config.get("timeout", 30) + + # 构建参数 + params = [] + params_doc = [] + + # 支持两种格式: + # 1. {"properties": {"symbol": {...}}} + # 2. {"symbol": {...}} (直接参数格式) + param_props = request_params + if request_params and request_params.get("properties"): + param_props = request_params["properties"] + elif request_params and not any(k in request_params for k in ["type", "required", "description"]): + param_props = request_params + else: + param_props = {} + + if param_props: + for p_name, p_info in param_props.items(): + if not isinstance(p_info, dict): + continue + p_type = self._json_type_to_python(p_info.get("type", "string")) + p_desc = p_info.get("description", "") + is_required = p_info.get("required", False) + default = p_info.get("default") + + if is_required: + params.append(f"{p_name}: {p_type}") + else: + default_val = f'"{default}"' if isinstance(default, str) else (default if default is not None else "None") + params.append(f"{p_name}: Optional[{p_type}] = {default_val}") + + params_doc.append(f" {p_name}: {p_desc}") + + params_str = ", ".join(params) if params else "" + params_doc_str = "\n".join(params_doc) if params_doc else " 无参数" + + # 生成认证代码 + auth_headers = self._get_auth_headers_code(auth) + + # 构建参数字典代码 + params_dict_code = "" + if param_props: + params_dict_code = "params = {" + for p_name in param_props.keys(): + if isinstance(param_props[p_name], dict): + params_dict_code += f'"{p_name}": {p_name}, ' + params_dict_code = params_dict_code.rstrip(", ") + "}" + else: + params_dict_code = "params = {}" + + # API Key in query + if auth and auth.get("type") == "api_key" and auth.get("in") == "query": + key_name = auth.get("name", "apikey") + params_dict_code += f'\n params["{key_name}"] = os.getenv("TOOL_API_KEY", "")' + + # 生成函数代码 + code = f'''""" +工具: {name} +描述: {desc} +自动生成时间: {datetime.now().isoformat()} +""" +import os +import json +from typing import Optional, Any +import httpx + + +async def {func_name}({params_str}) -> str: + """ + {desc} + + Args: +{params_doc_str} + + Returns: + API 响应结果 (JSON 格式) + """ + url = "{url}" + {auth_headers} + {params_dict_code} + + try: + async with httpx.AsyncClient(timeout={timeout}) as client: + response = await client.request( + method="{method}", + url=url, + headers=headers, + params={{k: v for k, v in params.items() if v is not None}} + ) + + if response.status_code == 200: + return json.dumps({{ + "success": True, + "data": response.json() if response.headers.get("content-type", "").startswith("application/json") else response.text + }}, ensure_ascii=False, indent=2) + else: + return json.dumps({{ + "success": False, + "status_code": response.status_code, + "error": response.text[:500] + }}, ensure_ascii=False) + + except Exception as e: + return json.dumps({{ + "success": False, + "error": str(e) + }}, ensure_ascii=False) +''' + return code + def generate_mcp_server( self, agent_name: str, @@ -377,23 +506,40 @@ if __name__ == '__main__': - 对于操作请求:返回操作状态和结果 - 始终使用中文与用户交流(除非用户使用其他语言)''' - def generate_api_server(self, agent_name: str, description: str) -> str: + def generate_api_server(self, agent_name: str, description: str, tools_config: List[Dict] = None) -> str: """ 生成 API 服务器代码 (api_server.py) 基于 _template/src/server/api_server.py 框架 + 集成回调功能用于计费 + + Args: + agent_name: Agent 名称 + description: Agent 描述 + tools_config: 工具配置列表(用于生成工具名称列表) """ + # 生成工具名称列表(用于回调) + tool_names = [] + if tools_config: + for tool in tools_config: + tool_names.append(tool.get("name", "unknown_tool")) + + tool_names_str = json.dumps(tool_names, ensure_ascii=False) + return f'''""" HTTP API 服务器 - {agent_name} {description} 提供 REST API 和 MCP HTTP/SSE 端点。 +集成回调功能用于计费。 自动生成时间: {datetime.now().isoformat()} """ import json import uuid import os -from typing import Optional, Dict, Any, AsyncGenerator +import logging +from typing import Optional, Dict, Any, AsyncGenerator, List from contextlib import asynccontextmanager +from datetime import datetime from fastapi import FastAPI, HTTPException, Request, Header, Depends from fastapi.middleware.cors import CORSMiddleware @@ -401,19 +547,47 @@ from fastapi.responses import StreamingResponse, JSONResponse from pydantic import BaseModel, Field from .mcp_server import TOOL_MAP, TOOL_LIST +from .agent_callback_utils import AgentCallbackHandler, CallbackContextManager # ==================== 配置 ==================== SERVER_NAME = "{agent_name}" +POD_NAME = os.getenv("POD_NAME", "{agent_name}") +USER_ID = os.getenv("USER_ID", "") + +# 配置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# 本 Agent 包含的工具列表 +AGENT_TOOLS = {tool_names_str} + +# ==================== 回调处理器 ==================== + +callback_handler: Optional[AgentCallbackHandler] = None + + +def get_callback_handler() -> AgentCallbackHandler: + """获取或创建回调处理器(单例)""" + global callback_handler + if callback_handler is None: + callback_handler = AgentCallbackHandler( + agent_name=POD_NAME, + user_id=USER_ID + ) + return callback_handler # ==================== FastAPI 应用 ==================== @asynccontextmanager async def lifespan(app: FastAPI): - print(f"🚀 {{SERVER_NAME}} 启动") + logger.info(f"🚀 {{SERVER_NAME}} 启动") + logger.info(f" 包含工具: {{', '.join(AGENT_TOOLS)}}") + logger.info(f" Pod 名称: {{POD_NAME}}") + logger.info(f" 用户 ID: {{USER_ID or '未设置'}}") yield - print(f"🛑 {{SERVER_NAME}} 关闭") + logger.info(f"🛑 {{SERVER_NAME}} 关闭") app = FastAPI( title=SERVER_NAME, @@ -467,13 +641,15 @@ async def root(): return {{ "service": SERVER_NAME, "status": "running", - "tools": list(TOOL_MAP.keys()) + "tools": list(TOOL_MAP.keys()), + "tools_count": len(TOOL_MAP), + "pod_name": POD_NAME }} @app.get("/health") async def health(): - return {{"status": "healthy", "service": SERVER_NAME}} + return {{"status": "healthy", "service": SERVER_NAME, "tools_count": len(TOOL_MAP)}} # ==================== MCP 端点 ==================== @@ -481,8 +657,8 @@ async def health(): sessions: Dict[str, Dict] = {{}} -async def handle_mcp_request(data: Dict, session_id: str = None, api_key: str = None) -> Dict: - """处理 MCP JSON-RPC 请求""" +async def handle_mcp_request(data: Dict, session_id: str = None, api_key: str = None, user_id: str = None) -> Dict: + """处理 MCP JSON-RPC 请求(带回调)""" method = data.get("method") params = data.get("params", {{}}) req_id = data.get("id") @@ -515,8 +691,21 @@ async def handle_mcp_request(data: Dict, session_id: str = None, api_key: str = if api_key: os.environ['OPENAI_API_KEY'] = api_key + # 使用回调上下文管理器(如果有 user_id) + effective_user_id = user_id or USER_ID + try: - result = await TOOL_MAP[tool_name](**args) + if effective_user_id: + handler = get_callback_handler() + with CallbackContextManager( + handler=handler, + user_id=effective_user_id, + request_id=f"mcp-{{req_id}}-{{int(datetime.utcnow().timestamp())}}" + ) as ctx: + ctx.add_tool(tool_name) + result = await TOOL_MAP[tool_name](**args) + else: + result = await TOOL_MAP[tool_name](**args) finally: if old_key: os.environ['OPENAI_API_KEY'] = old_key @@ -543,7 +732,8 @@ async def mcp_endpoint(request: Request): body = await request.json() session_id = request.headers.get("x-mcp-session-id") api_key = get_api_key_from_request(request) - response = await handle_mcp_request(body, session_id, api_key) + user_id = request.headers.get("x-user-id") or USER_ID + response = await handle_mcp_request(body, session_id, api_key, user_id) return JSONResponse(content=response, headers={{"x-mcp-session-id": session_id or ""}}) except Exception as e: return JSONResponse(status_code=400, content={{"jsonrpc": "2.0", "error": {{"code": -32700, "message": str(e)}}}}) @@ -572,9 +762,10 @@ async def mcp_sse_post(request: Request): body = await request.json() session_id = request.headers.get("x-mcp-session-id") or str(uuid.uuid4()) api_key = get_api_key_from_request(request) + user_id = request.headers.get("x-user-id") or USER_ID async def stream() -> AsyncGenerator[str, None]: - response = await handle_mcp_request(body, session_id, api_key) + response = await handle_mcp_request(body, session_id, api_key, user_id) yield f"data: {{json.dumps(response)}}\\n\\n" return StreamingResponse(stream(), media_type="text/event-stream", @@ -589,6 +780,13 @@ class ToolCallRequest(BaseModel): """工具调用请求""" tool_name: str = Field(..., description="工具名称") parameters: Dict[str, Any] = Field(default={{}}, description="工具参数") + user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)") + + +class MultiToolCallRequest(BaseModel): + """批量工具调用请求""" + calls: List[ToolCallRequest] = Field(..., description="工具调用列表") + user_id: Optional[str] = Field(None, description="用户ID(用于计费回调)") class ToolCallResponse(BaseModel): @@ -596,6 +794,15 @@ class ToolCallResponse(BaseModel): success: bool result: Optional[Any] = None error: Optional[str] = None + tools_used: Optional[List[str]] = None + + +class MultiToolCallResponse(BaseModel): + """批量工具调用响应""" + success: bool + results: List[ToolCallResponse] + tools_used: List[str] + total_calls: int @app.get("/tools") @@ -605,28 +812,117 @@ async def list_tools(): "tools": [ {{"name": t["name"], "description": t["description"]}} for t in TOOL_LIST - ] + ], + "count": len(TOOL_LIST) }} @app.post("/tools/call", response_model=ToolCallResponse) async def call_tool(request: ToolCallRequest, api_key: str = Depends(verify_api_key)): - """调用工具""" + """调用单个工具(带计费回调)""" if request.tool_name not in TOOL_MAP: raise HTTPException(status_code=404, detail=f"工具 {{request.tool_name}} 不存在") + effective_user_id = request.user_id or USER_ID + tools_used = [request.tool_name] + try: old_key = os.environ.get('OPENAI_API_KEY') os.environ['OPENAI_API_KEY'] = api_key try: - result = await TOOL_MAP[request.tool_name](**request.parameters) - return ToolCallResponse(success=True, result=json.loads(result) if isinstance(result, str) else result) + # 使用回调上下文管理器 + if effective_user_id: + handler = get_callback_handler() + with CallbackContextManager( + handler=handler, + user_id=effective_user_id, + request_id=f"api-{{int(datetime.utcnow().timestamp())}}" + ) as ctx: + ctx.add_tool(request.tool_name) + result = await TOOL_MAP[request.tool_name](**request.parameters) + else: + result = await TOOL_MAP[request.tool_name](**request.parameters) + + return ToolCallResponse( + success=True, + result=json.loads(result) if isinstance(result, str) else result, + tools_used=tools_used + ) finally: if old_key: os.environ['OPENAI_API_KEY'] = old_key except Exception as e: - return ToolCallResponse(success=False, error=str(e)) + logger.error(f"工具调用失败: {{e}}") + return ToolCallResponse(success=False, error=str(e), tools_used=tools_used) + + +@app.post("/tools/batch-call", response_model=MultiToolCallResponse) +async def batch_call_tools(request: MultiToolCallRequest, api_key: str = Depends(verify_api_key)): + """批量调用多个工具(带计费回调)""" + effective_user_id = request.user_id or USER_ID + results = [] + tools_used = [] + + # 设置 API Key + old_key = os.environ.get('OPENAI_API_KEY') + os.environ['OPENAI_API_KEY'] = api_key + + try: + # 使用回调上下文管理器 + if effective_user_id: + handler = get_callback_handler() + with CallbackContextManager( + handler=handler, + user_id=effective_user_id, + request_id=f"batch-{{int(datetime.utcnow().timestamp())}}" + ) as ctx: + for call in request.calls: + if call.tool_name not in TOOL_MAP: + results.append(ToolCallResponse( + success=False, + error=f"工具 {{call.tool_name}} 不存在" + )) + continue + + try: + ctx.add_tool(call.tool_name) + tools_used.append(call.tool_name) + result = await TOOL_MAP[call.tool_name](**call.parameters) + results.append(ToolCallResponse( + success=True, + result=json.loads(result) if isinstance(result, str) else result + )) + except Exception as e: + results.append(ToolCallResponse(success=False, error=str(e))) + else: + for call in request.calls: + if call.tool_name not in TOOL_MAP: + results.append(ToolCallResponse( + success=False, + error=f"工具 {{call.tool_name}} 不存在" + )) + continue + + try: + tools_used.append(call.tool_name) + result = await TOOL_MAP[call.tool_name](**call.parameters) + results.append(ToolCallResponse( + success=True, + result=json.loads(result) if isinstance(result, str) else result + )) + except Exception as e: + results.append(ToolCallResponse(success=False, error=str(e))) + finally: + if old_key: + os.environ['OPENAI_API_KEY'] = old_key + + return MultiToolCallResponse( + success=all(r.success for r in results), + results=results, + tools_used=list(set(tools_used)), + total_calls=len(request.calls) + ) if __name__ == '__main__': @@ -701,11 +997,221 @@ uvicorn[standard]>=0.27.0 # HTTP Client httpx>=0.25.0 aiohttp>=3.9.0 +requests>=2.31.0 # 其他 python-dotenv>=1.0.0 ''' + def generate_callback_utils(self) -> str: + """生成回调工具代码 (用于计费)""" + return '''""" +Agent回调工具 - 用于向Agent Manager回调运行时长记录(计费) +""" +import os +import time +import logging +import requests +from typing import Optional, List +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +class AgentCallbackHandler: + """Agent回调处理器""" + + def __init__( + self, + agent_name: Optional[str] = None, + user_id: Optional[str] = None, + callback_url: Optional[str] = None + ): + """ + 初始化回调处理器 + + Args: + agent_name: Agent名称,默认从环境变量 POD_NAME 获取 + user_id: 用户ID,默认从环境变量 USER_ID 获取 + callback_url: 回调URL,默认从环境变量 AGENT_CALLBACK_URL 获取 + """ + self.agent_name = agent_name or os.getenv("POD_NAME", "unknown-agent") + self.user_id = user_id or os.getenv("USER_ID", "") + self.callback_url = callback_url or os.getenv( + "AGENT_CALLBACK_URL", + "http://mcp-server.taiji-ai.svc.cluster.local:8000/api/v1/billing/agent-callback" + ) + + self.start_time: Optional[datetime] = None + self.tools_used: List[str] = [] + self.request_id: Optional[str] = None + + logger.info(f"AgentCallbackHandler initialized: agent={self.agent_name}, callback_url={self.callback_url}") + + def start_request(self, request_id: Optional[str] = None, user_id: Optional[str] = None): + """ + 开始一次请求处理 + + Args: + request_id: 请求ID + user_id: 用户ID(如果提供则覆盖默认值) + """ + self.start_time = datetime.now(timezone.utc) + self.tools_used = [] + self.request_id = request_id or f"req-{int(time.time())}" + + if user_id: + self.user_id = user_id + + logger.info(f"Request started: request_id={self.request_id}, user_id={self.user_id}") + + def add_tool_used(self, tool_name: str): + """ + 记录使用的工具 + + Args: + tool_name: 工具名称 + """ + if tool_name not in self.tools_used: + self.tools_used.append(tool_name) + logger.debug(f"Tool used: {tool_name}") + + def end_request(self, tools_used: Optional[List[str]] = None) -> bool: + """ + 结束请求并发送回调 + + Args: + tools_used: 使用的工具列表(可选,如果提供则覆盖内部记录) + + Returns: + 是否成功发送回调 + """ + if not self.start_time: + logger.warning("Cannot end request: no start time recorded") + return False + + if not self.user_id: + logger.warning("Cannot send callback: user_id not set") + return False + + end_time = datetime.now(timezone.utc) + running_time = (end_time - self.start_time).total_seconds() + + # 使用提供的工具列表或内部记录 + final_tools_used = tools_used if tools_used is not None else self.tools_used + + # 发送回调 + success = self._send_callback( + running_time_seconds=int(running_time), + start_time=self.start_time, + end_time=end_time, + tools_used=final_tools_used + ) + + # 重置状态 + self.start_time = None + self.tools_used = [] + self.request_id = None + + return success + + def _send_callback( + self, + running_time_seconds: int, + start_time: datetime, + end_time: datetime, + tools_used: List[str] + ) -> bool: + """ + 发送回调到Agent Manager + + Args: + running_time_seconds: 运行时长(秒) + start_time: 开始时间 + end_time: 结束时间 + tools_used: 使用的工具列表 + + Returns: + 是否成功发送 + """ + try: + payload = { + "agentName": self.agent_name, + "userId": self.user_id, + "podRunningTimeSeconds": running_time_seconds, + "toolsUsed": tools_used, + "startTime": start_time.isoformat(), + "endTime": end_time.isoformat(), + "requestId": self.request_id + } + + logger.info(f"Sending callback: {payload}") + + response = requests.post( + self.callback_url, + json=payload, + timeout=5 + ) + + if response.status_code == 200: + logger.info(f"Callback sent successfully: {response.json()}") + return True + else: + logger.error(f"Callback failed with status {response.status_code}: {response.text}") + return False + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to send callback: {str(e)}") + return False + except Exception as e: + logger.error(f"Unexpected error sending callback: {str(e)}") + return False + + +class CallbackContextManager: + """回调上下文管理器 - 使用with语句自动处理开始和结束""" + + def __init__( + self, + handler: AgentCallbackHandler, + request_id: Optional[str] = None, + user_id: Optional[str] = None, + tools_used: Optional[List[str]] = None + ): + """ + 初始化上下文管理器 + + Args: + handler: AgentCallbackHandler实例 + request_id: 请求ID + user_id: 用户ID + tools_used: 使用的工具列表(可选) + """ + self.handler = handler + self.request_id = request_id + self.user_id = user_id + self.tools_used = tools_used or [] + + def __enter__(self): + """进入上下文时开始计时""" + self.handler.start_request( + request_id=self.request_id, + user_id=self.user_id + ) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """退出上下文时发送回调""" + self.handler.end_request(tools_used=self.tools_used) + return False # 不抑制异常 + + def add_tool(self, tool_name: str): + """添加使用的工具""" + self.handler.add_tool_used(tool_name) + if tool_name not in self.tools_used: + self.tools_used.append(tool_name) +''' + def generate_gitea_action(self, agent_name: str, auto_deploy: bool = True) -> str: """ 生成 Gitea Actions CI/CD 配置 @@ -1068,11 +1574,12 @@ MIT License """ 生成完整的 Agent 项目文件 基于 agent_templates/agents/_template 框架 + 包含回调功能用于计费 Args: agent_name: Agent 名称 description: Agent 描述 - tools_config: 工具配置列表 + tools_config: 工具配置列表(支持多个工具) auto_deploy: 是否自动部署到 AKS Returns: @@ -1087,8 +1594,13 @@ MIT License agent_name, description, tools_config ) - # 生成 src/server/api_server.py - files["src/server/api_server.py"] = self.generate_api_server(agent_name, description) + # 生成 src/server/api_server.py(传递 tools_config 用于回调) + files["src/server/api_server.py"] = self.generate_api_server( + agent_name, description, tools_config + ) + + # 生成 src/server/agent_callback_utils.py(回调工具) + files["src/server/agent_callback_utils.py"] = self.generate_callback_utils() # 生成 src/server/__init__.py files["src/server/__init__.py"] = '"""服务器模块"""\n' @@ -1111,6 +1623,12 @@ MIT License # 生成 README files["README.md"] = self.generate_readme(agent_name, description, tools_config, auto_deploy) + logger.info(f"生成 Agent 项目: {agent_name}") + logger.info(f" 包含工具: {len(tools_config)} 个") + logger.info(f" 工具列表: {[t.get('name') for t in tools_config]}") + logger.info(f" 自动部署: {auto_deploy}") + logger.info(f" 包含回调功能: 是") + return files def get_cicd_secrets_info(self) -> Dict[str, str]: diff --git a/app.py b/app.py index a699668..5860da6 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,8 @@ from database import ( ) from template_manager import template_manager from tool_generator_api import router as tool_generator_router +from external_tool_api import router as external_tool_router +from tool_storage import tool_storage import os # 配置日志 @@ -32,6 +34,9 @@ app = FastAPI( # 注册动态工具生成 Router app.include_router(tool_generator_router) +# 注册外部工具 API Router(符合 MCP-Server 规范) +app.include_router(external_tool_router) + # 初始化K8s管理器 NAMESPACE = os.getenv("NAMESPACE", "ai-agents") KUBECONFIG_PATH = os.getenv("KUBECONFIG_PATH", None) # 可选:指定kubeconfig路径 @@ -207,6 +212,7 @@ class CreateAgentRequest(BaseModel): config: Dict = Field(default_factory=dict, description="配置信息") env: Optional[Dict[str, str]] = Field(default_factory=dict, description="环境变量") namespace: Optional[str] = Field(default=None, description="Kubernetes命名空间,默认使用环境变量NAMESPACE的值") + tool_refs: Optional[List[str]] = Field(default=None, description="外部数据工具标识列表(符合MCP-Server规范)") class AgentResponse(BaseModel): @@ -224,6 +230,7 @@ class AgentResponse(BaseModel): host_ip: Optional[str] = None node_name: Optional[str] = None owner_info: Optional[Dict] = None + tools_attached: Optional[int] = Field(default=0, description="附加的外部工具数量") class ResourceUsage(BaseModel): @@ -328,6 +335,36 @@ async def create_agent(request: CreateAgentRequest, db: Session = Depends(get_db config_data["env"] = request.env logger.info(f"环境变量: {list(request.env.keys())}") + # 处理 tool_refs(符合 MCP-Server 规范) + attached_tools = [] + if request.tool_refs: + logger.info(f"📦 处理外部工具引用: {request.tool_refs}") + + # 验证所有工具存在 + missing_tools = [] + for ref in request.tool_refs: + tool = tool_storage.get_tool(ref) + if tool: + attached_tools.append(tool) + else: + missing_tools.append(ref) + + if missing_tools: + raise HTTPException( + status_code=404, + detail=f"以下工具不存在: {', '.join(missing_tools)}" + ) + + # 将工具配置添加到 config + config_data["tool_refs"] = request.tool_refs + config_data["tools_count"] = len(attached_tools) + + # 标记工具被使用 + for ref in request.tool_refs: + tool_storage.mark_tool_in_use(ref, request.name) + + logger.info(f"✅ 已附加 {len(attached_tools)} 个外部工具") + # 添加 user_id 标签 user_id = config_data.get("user_id", "default") if "labels" not in config_data: @@ -516,6 +553,10 @@ async def create_agent(request: CreateAgentRequest, db: Session = Depends(get_db if "status" not in result: result["status"] = "Pending" + # 添加外部工具信息 + if attached_tools: + result["tools_attached"] = len(attached_tools) + try: return AgentResponse(**result) except Exception as validation_error: diff --git a/docs/DNS_ISSUE_FIX_REPORT.md b/docs/DNS_ISSUE_FIX_REPORT.md new file mode 100644 index 0000000..b46fe9d --- /dev/null +++ b/docs/DNS_ISSUE_FIX_REPORT.md @@ -0,0 +1,223 @@ +# Agent Manager DNS问题修复报告 + +## 问题描述 + +在K8s上部署的Agent Manager服务,创建新agent后没有按照预期返回域名和外网地址。 + +## 问题诊断 + +### 1. 症状 +- 创建agent时返回外网IP地址(`external_ip`) +- 但没有返回DNS域名信息(`domain`, `domain_url`) +- 推荐访问地址使用的是IP地址而非域名 + +### 2. 根本原因 +通过查看agent-manager日志发现: +``` +ERROR:k8s_manager:创建 DNS 记录失败: 403 Client Error: Forbidden +ERROR:k8s_manager:错误详情: {"error":{"code":"AuthorizationFailed", + "message":"The client 'c5ba26db-f180-425f-bac3-93708d853988' with object id + '9dd70c60-bca4-478f-8cac-9faf9f171c93' does not have authorization to perform + action 'Microsoft.Network/dnsZones/A/write' over scope + '/subscriptions/45d7a360-af09-40fc-9afc-56dc475245ec/resourceGroups/taiji-ai-v0/ + providers/Microsoft.Network/dnsZones/taijiagnet.com/A/...' or the scope is invalid."}} +``` + +**核心问题:** Azure Service Principal缺少DNS Zone的写权限。 + +## 解决方案 + +### 步骤1:为Service Principal分配DNS Zone Contributor角色 + +```bash +# Azure配置 +AZURE_CLIENT_ID="c5ba26db-f180-425f-bac3-93708d853988" +AZURE_SUBSCRIPTION_ID="45d7a360-af09-40fc-9afc-56dc475245ec" +AZURE_RESOURCE_GROUP="taiji-ai-v0" +AZURE_DNS_ZONE="taijiagnet.com" + +# 构建DNS Zone资源ID +DNS_ZONE_ID="/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.Network/dnsZones/$AZURE_DNS_ZONE" + +# 分配DNS Zone Contributor角色 +az role assignment create \ + --assignee $AZURE_CLIENT_ID \ + --role "DNS Zone Contributor" \ + --scope $DNS_ZONE_ID +``` + +### 步骤2:验证权限分配 + +```bash +# 验证角色分配 +az role assignment list \ + --assignee $AZURE_CLIENT_ID \ + --scope $DNS_ZONE_ID \ + --output table +``` + +输出应显示: +``` +Principal Role Scope +------------------------------------ -------------------- -------- +c5ba26db-f180-425f-bac3-93708d853988 DNS Zone Contributor /subscriptions/.../dnsZones/taijiagnet.com +``` + +## 修复验证 + +### 测试1:创建新agent +```bash +curl -X POST http://20.212.121.126/agents \ + -H "Content-Type: application/json" \ + -d '{ + "name": "test-dns-fixed", + "template": "echo_agent", + "framework": "API", + "config": {"user_id": "test-user"} + }' +``` + +### 结果(修复前) +```json +{ + "access_info": { + "external_ip": "20.212.128.51", + "ip_url": "http://20.212.128.51:80", + "recommended": "http://20.212.128.51:80" + } +} +``` + +### 结果(修复后)✅ +```json +{ + "access_info": { + "external_ip": "20.198.137.237", + "ip_url": "http://20.198.137.237:80", + "service_url": "http://10.0.48.107:80", + "pod_url": "http://10.224.0.67:8000", + "domain": "test-dns-fixed.taijiagnet.com", + "domain_url": "http://test-dns-fixed.taijiagnet.com", + "recommended": "http://test-dns-fixed.taijiagnet.com" + } +} +``` + +### 测试2:验证DNS记录 +```bash +# 查看DNS记录 +az network dns record-set a show \ + --resource-group taiji-ai-v0 \ + --zone-name taijiagnet.com \ + --name test-dns-fixed + +# DNS解析测试 +nslookup test-dns-fixed.taijiagnet.com +# Output: Address: 20.198.137.237 + +# 域名访问测试 +curl http://test-dns-fixed.taijiagnet.com/ +# Output: {"status":"healthy",...} +``` + +### 测试3:查询agent状态 +```bash +curl http://20.212.121.126/agents/test-dns-fixed/status | jq .access_info +``` + +结果: +```json +{ + "external_ip": "20.198.137.237", + "ip_url": "http://20.198.137.237:80", + "domain": "test-dns-fixed.taijiagnet.com", + "domain_url": "http://test-dns-fixed.taijiagnet.com", + "recommended_url": "http://test-dns-fixed.taijiagnet.com", + "service_name": "test-dns-fixed-service" +} +``` + +## Agent Manager日志(修复后) + +``` +INFO:app:收到创建Agent请求: test-dns-fixed, 模板: echo_agent +INFO:k8s_manager:✅ 创建命名空间 agent-test-dns-fixed +INFO:k8s_manager:✅ LoadBalancer Service test-dns-fixed-service 创建成功 +INFO:k8s_manager:✅ LoadBalancer IP 已分配: 20.198.137.237 +INFO:app:✅ LoadBalancer 外网 IP: 20.198.137.237 +INFO:app:创建 DNS 记录: test-dns-fixed.taijiagnet.com +INFO:k8s_manager:✅ DNS 记录创建成功: test-dns-fixed.taijiagnet.com -> 20.198.137.237 +INFO:app:✅ DNS 记录: test-dns-fixed.taijiagnet.com -> 20.198.137.237 +INFO:app: - 推荐访问: http://test-dns-fixed.taijiagnet.com +INFO:app:✅ Agent创建成功! +INFO:app:✅ Agent信息已保存到数据库 +``` + +## 涉及的文件和组件 + +1. **Azure Service Principal** (c5ba26db-f180-425f-bac3-93708d853988) + - 需要DNS Zone Contributor权限 + +2. **k8s_manager.py** - DNS记录创建逻辑 + - `create_dns_record()` - 使用Azure DNS API创建A记录 + - `wait_for_loadbalancer_ip()` - 等待LoadBalancer IP分配 + +3. **app.py** - Agent创建流程 + - 在创建LoadBalancer Service后自动创建DNS记录 + - 将域名信息保存到数据库 + +4. **database.py** - Agent模型 + - 存储`domain`, `domain_url`, `recommended_url`等字段 + +## 修复完成清单 ✅ + +- [x] 诊断DNS创建失败原因(Azure权限不足) +- [x] 为Service Principal分配DNS Zone Contributor角色 +- [x] 测试DNS记录创建功能 +- [x] 验证agent创建返回完整的域名信息 +- [x] 验证agent状态查询返回完整的访问信息 +- [x] 验证DNS解析和域名访问正常 +- [x] 清理测试资源 + +## 后续建议 + +### 1. 文档更新 +更新部署文档,明确说明Service Principal需要的Azure权限: +- AKS Cluster的Contributor角色 +- DNS Zone的DNS Zone Contributor角色 + +### 2. 自动化部署脚本 +在`k8s/deploy.sh`中添加权限检查和自动分配逻辑: +```bash +# 检查并分配DNS Zone权限 +check_dns_permissions() { + echo "检查DNS Zone权限..." + # 实现权限检查逻辑 +} +``` + +### 3. 监控和告警 +添加DNS创建失败的监控和告警机制: +- 记录DNS创建失败次数 +- 当失败超过阈值时发送告警 + +### 4. 重试机制 +在`k8s_manager.py`中为DNS记录创建添加重试逻辑: +```python +def create_dns_record_with_retry(self, subdomain, ip_address, max_retries=3): + for attempt in range(max_retries): + try: + return self.create_dns_record(subdomain, ip_address) + except Exception as e: + if attempt < max_retries - 1: + logger.warning(f"DNS创建失败,重试 {attempt + 1}/{max_retries}") + time.sleep(5) + else: + raise +``` + +## 修复日期 +2026-01-28 + +## 修复人员 +AI Assistant (Claude Sonnet 4.5) diff --git a/docs/DYNAMIC_AGENT_GENERATOR_API.md b/docs/DYNAMIC_AGENT_GENERATOR_API.md index 422ffbc..463dbe7 100644 --- a/docs/DYNAMIC_AGENT_GENERATOR_API.md +++ b/docs/DYNAMIC_AGENT_GENERATOR_API.md @@ -1,7 +1,7 @@ # 动态 Agent 生成器 API 文档 -> **版本**: 2026-01-26 v1.0 -> **服务地址**: http://57.158.136.164 +> **版本**: 2026-01-29 v1.1 +> **服务地址**: http://20.212.121.126 > **模板参考**: [cicd-AKS](http://gitee.ath.cx:3000/xiaohei/cicd-AKS) --- @@ -29,6 +29,12 @@ │ │ AKS │ │ │ │ (部署) │ │ │ └──────────┘ │ +│ │ │ +│ ↓ │ +│ ┌──────────┐ │ +│ │Azure DNS │ │ +│ │ (域名) │ │ +│ └──────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────────────┘ ``` @@ -41,33 +47,40 @@ | 配置项 | 值 | |--------|-----| -| 终结点 | `https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1` | -| 模型 | `taiji/claude-sonnet-4-5` | -| API Key | `sk-mPV5MVVVVvfGSkXA-ASQXQ` | +| 终结点 | \`https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1\` | +| 模型 | \`taiji/claude-sonnet-4-5\` | +| API Key | \`sk-mPV5MVVVVvfGSkXA-ASQXQ\` | ### Gitee 配置 | 配置项 | 值 | |--------|-----| -| API URL | `http://gitee.ath.cx:3000/api/v1` | -| 用户名 | `zhanggangyong` | -| Owner | `xiaohei` | +| API URL | \`http://gitee.ath.cx:3000/api/v1\` | +| 用户名 | \`zhanggangyong\` | +| Owner | \`xiaohei\` | | 模板仓库 | [cicd-AKS](http://gitee.ath.cx:3000/xiaohei/cicd-AKS) | ### ACR 配置 | 配置项 | 值 | |--------|-----| -| Registry | `agnettaiji.azurecr.io` | -| Namespace | `ai-agents` | +| Registry | \`agnettaiji.azurecr.io\` | +| Namespace | \`ai-agents\` | + +### Azure DNS 配置 + +| 配置项 | 值 | +|--------|-----| +| DNS Zone | \`taijiagnet.com\` | +| 资源组 | \`taiji-ai-v0\` | ### WAF 白名单 (重要!) Gitee 服务器使用雷池 WAF 保护,需要将以下 IP 添加到白名单: -``` +\`\`\` 4.144.251.116 (AKS 集群出口 IP) -``` +\`\`\` --- @@ -75,15 +88,15 @@ Gitee 服务器使用雷池 WAF 保护,需要将以下 IP 添加到白名单 | 序号 | 接口 | 方法 | 说明 | |------|------|------|------| -| 1 | `/tools/generate` | POST | 生成单个外部数据工具 | -| 2 | `/tools/generate-agent` | POST | 生成完整 Agent 并推送到 Gitee | -| 3 | `/tools/{tool_ref_id}` | GET | 获取工具/Agent 信息 | -| 4 | `/tools/{tool_ref_id}` | PUT | 更新工具配置 | -| 5 | `/tools/{tool_ref_id}` | DELETE | 删除工具 | -| 6 | `/tools/{tool_ref_id}/test` | POST | 测试工具连接 | -| 7 | `/tools/{tool_ref_id}/build-status` | GET | 查询构建状态 | -| 8 | `/tools/{tool_ref_id}/deploy` | POST | 部署 Agent | -| 9 | `/tools/` | GET | 列出所有工具/Agent | +| 1 | \`/tools/generate\` | POST | 生成单个外部数据工具 | +| 2 | \`/tools/generate-agent\` | POST | 生成完整 Agent 并推送到 Gitee | +| 3 | \`/tools/{tool_ref_id}\` | GET | 获取工具/Agent 信息 | +| 4 | \`/tools/{tool_ref_id}\` | PUT | 更新工具配置 | +| 5 | \`/tools/{tool_ref_id}\` | DELETE | 删除工具 | +| 6 | \`/tools/{tool_ref_id}/test\` | POST | 测试工具连接 | +| 7 | \`/tools/{tool_ref_id}/build-status\` | GET | 查询构建状态 | +| 8 | \`/tools/{tool_ref_id}/deploy\` | POST | 部署 Agent | +| 9 | \`/tools/\` | GET | 列出所有工具/Agent | --- @@ -91,9 +104,9 @@ Gitee 服务器使用雷池 WAF 保护,需要将以下 IP 添加到白名单 ### 接口 -``` +\`\`\` POST /tools/generate -``` +\`\`\` ### 请求参数 @@ -114,39 +127,39 @@ POST /tools/generate #### API Key 认证 -```json +\`\`\`json { "type": "api_key", "key": "sk-xxxxxxxxxxxx", "in": "header", "name": "X-API-Key" } -``` +\`\`\` #### Bearer Token 认证 -```json +\`\`\`json { "type": "bearer", "key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } -``` +\`\`\` #### Basic Auth 认证 -```json +\`\`\`json { "type": "basic", "username": "admin", "password": "password123" } -``` +\`\`\` ### 请求示例 -```bash -curl -X POST http://57.158.136.164/tools/generate \ - -H "Content-Type: application/json" \ +\`\`\`bash +curl -X POST http://20.212.121.126/tools/generate \\ + -H "Content-Type: application/json" \\ -d '{ "name": "weather-query", "description": "查询城市天气", @@ -170,22 +183,78 @@ curl -X POST http://57.158.136.164/tools/generate \ } } }' -``` +\`\`\` ### 响应示例 -```json +\`\`\`json { "success": true, "data": { "tool_ref_id": "tool-weather-query-a49e00fe", "name": "weather-query", "description": "查询城市天气", - "created_at": "2026-01-26T10:00:00.000000" + "created_at": "2026-01-29T10:00:00.000000" }, "message": "工具生成成功" } -``` +\`\`\` + +### 生成的工具代码示例 + +\`\`\`python +""" +工具: weather-query +描述: 查询城市天气 +""" +import os +import json +from typing import Optional, Any +import httpx + + +async def weather_query(city: str) -> str: + """ + 查询城市天气 + + Args: + city: 城市名称 + + Returns: + API 响应结果 (JSON 格式) + """ + url = "https://api.weather.com/v1/current" + headers = {} + params = {"city": city} + params["appid"] = os.getenv("TOOL_API_KEY", "") + + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.request( + method="GET", + url=url, + headers=headers, + params={k: v for k, v in params.items() if v is not None} + ) + + if response.status_code == 200: + return json.dumps({ + "success": True, + "data": response.json() + }, ensure_ascii=False, indent=2) + else: + return json.dumps({ + "success": False, + "status_code": response.status_code, + "error": response.text[:500] + }, ensure_ascii=False) + + except Exception as e: + return json.dumps({ + "success": False, + "error": str(e) + }, ensure_ascii=False) +\`\`\` --- @@ -193,9 +262,9 @@ curl -X POST http://57.158.136.164/tools/generate \ ### 接口 -``` +\`\`\` POST /tools/generate-agent -``` +\`\`\` ### 功能描述 @@ -203,6 +272,7 @@ POST /tools/generate-agent 2. 创建 Gitee 仓库并推送代码 3. 包含完整 CI/CD 配置(参考 [cicd-AKS](http://gitee.ath.cx:3000/xiaohei/cicd-AKS)) 4. 自动触发构建和部署 +5. 自动创建 Azure DNS 记录 ### 请求参数 @@ -213,13 +283,13 @@ POST /tools/generate-agent | tools | array | ✅ | 工具配置列表 | | user_id | string | ✅ | 用户 ID | | tenant_id | string | ❌ | 租户 ID | -| auto_deploy | boolean | ❌ | 是否自动部署到 AKS(默认 true) | +| auto_deploy | boolean | ❌ | 是否自动部署到 AKS(默认 false) | ### 请求示例 -```bash -curl -X POST http://57.158.136.164/tools/generate-agent \ - -H "Content-Type: application/json" \ +\`\`\`bash +curl -X POST http://20.212.121.126/tools/generate-agent \\ + -H "Content-Type: application/json" \\ -d '{ "agent_name": "weather-agent", "description": "天气查询智能 Agent,支持获取全球城市天气信息", @@ -254,31 +324,14 @@ curl -X POST http://57.158.136.164/tools/generate-agent \ } }, "timeout": 30 - }, - { - "name": "get_forecast", - "description": "获取城市未来天气预报", - "url": "https://api.openweathermap.org/data/2.5/forecast", - "method": "GET", - "user_id": "user-001", - "request_params": { - "type": "object", - "properties": { - "q": { - "type": "string", - "description": "城市名称", - "required": true - } - } - } } ] }' -``` +\`\`\` ### 响应示例 -```json +\`\`\`json { "success": true, "data": { @@ -287,256 +340,78 @@ curl -X POST http://57.158.136.164/tools/generate-agent \ "repo_url": "http://gitee.ath.cx:3000/zhanggangyong/agent-weather-agent-ac3c11", "image_name": "agnettaiji.azurecr.io/ai-agents/agent-weather-agent-ac3c11:latest", "status": "building", - "files_pushed": 7, - "tools_count": 2 + "files_pushed": 9, + "tools_count": 2, + "expected_domain": "agent-weather-agent-ac3c11.taijiagnet.com", + "expected_namespace": "agent-agent-weather-agent-ac3c11" }, - "message": "Agent 项目已创建并推送到 Gitee,CI/CD 正在构建中" + "message": "Agent 项目已创建并推送到 Gitee,CI/CD 正在构建中。部署后访问: http://agent-weather-agent-ac3c11.taijiagnet.com" } -``` +\`\`\` ### 生成的项目结构 -``` +\`\`\` agent-weather-agent-{id}/ ├── .gitea/workflows/ -│ └── ci-cd.yaml # CI/CD 配置(构建+部署) -├── tools/ +│ └── ci-cd.yaml # CI/CD 配置(构建+部署+DNS) +├── src/ │ ├── __init__.py -│ ├── get_weather.py # 工具1: 天气查询 -│ └── get_forecast.py # 工具2: 天气预报 -├── main.py # FastAPI 主程序 -├── Dockerfile # Docker 构建文件 -├── requirements.txt # Python 依赖 -└── README.md # 说明文档 -``` +│ └── server/ +│ ├── __init__.py +│ ├── mcp_server.py # MCP 服务器 + 工具定义 +│ └── api_server.py # FastAPI HTTP 服务器 +├── run_api_server.py # 入口文件 +├── Dockerfile # Docker 构建文件 +├── requirements.txt # Python 依赖 +└── README.md # 说明文档 +\`\`\` --- -## 3. 获取工具/Agent 信息 +## 3-9. 其他接口 -### 接口 +### 3. 获取工具/Agent 信息 -``` +\`\`\` GET /tools/{tool_ref_id} -``` +\`\`\` -### 响应示例 +### 4. 更新工具配置 -```json -{ - "success": true, - "data": { - "id": "agent-agent-weather-agent-ac3c11", - "name": "weather-agent", - "description": "天气查询智能 Agent", - "status": "building", - "created_at": "2026-01-26T10:00:00.000000", - "repo_url": "http://gitee.ath.cx:3000/zhanggangyong/agent-weather-agent-ac3c11", - "image_name": "agnettaiji.azurecr.io/ai-agents/agent-weather-agent-ac3c11:latest" - } -} -``` - ---- - -## 4. 更新工具配置 - -### 接口 - -``` +\`\`\` PUT /tools/{tool_ref_id} -``` +\`\`\` -### 请求参数 +### 5. 删除工具 -| 参数 | 类型 | 说明 | -|------|------|------| -| description | string | 工具描述 | -| url | string | API URL | -| method | string | HTTP 方法 | -| auth | object | 认证配置 | -| request_params | object | 请求参数 | -| timeout | integer | 超时时间 | - ---- - -## 5. 删除工具 - -### 接口 - -``` +\`\`\` DELETE /tools/{tool_ref_id}?delete_repo=false -``` +\`\`\` -### 参数 +### 6. 测试工具连接 -| 参数 | 类型 | 说明 | -|------|------|------| -| delete_repo | boolean | 是否同时删除 Gitee 仓库 | - ---- - -## 6. 测试工具连接 - -### 接口 - -``` +\`\`\` POST /tools/{tool_ref_id}/test -``` +\`\`\` -### 请求参数 +### 7. 查询构建状态 -```json -{ - "test_params": { - "q": "Beijing" - } -} -``` - -### 响应示例 - -```json -{ - "success": true, - "data": { - "status_code": 200, - "response_time_ms": 156.5, - "content_type": "application/json", - "response_preview": "{\"coord\":{\"lon\":116.4074,\"lat\":39.9042}...}" - }, - "message": "工具连接测试成功" -} -``` - ---- - -## 7. 查询构建状态 - -### 接口 - -``` +\`\`\` GET /tools/{tool_ref_id}/build-status -``` +\`\`\` -### 功能描述 +### 8. 部署 Agent -1. 查询 Gitee Action 运行状态 -2. 查询 ACR 镜像是否已构建完成 - -### 响应示例 - -```json -{ - "success": true, - "data": { - "tool_ref_id": "agent-agent-weather-agent-ac3c11", - "repo_name": "agent-weather-agent-ac3c11", - "overall_status": "ready", - "gitee_action": { - "status": "completed", - "conclusion": "success", - "run_id": 12345, - "html_url": "http://gitee.ath.cx:3000/zhanggangyong/agent-weather-agent-ac3c11/actions/runs/12345" - }, - "acr_image": { - "image_name": "agnettaiji.azurecr.io/ai-agents/agent-weather-agent-ac3c11:latest", - "exists": true, - "digest": "sha256:abc123...", - "created_at": "2026-01-26T10:05:00Z" - } - } -} -``` - -### 状态说明 - -| 状态 | 说明 | -|------|------| -| `building` | 正在构建中 | -| `ready` | 镜像已就绪,可以部署 | -| `build_failed` | 构建失败 | - ---- - -## 8. 部署 Agent - -### 接口 - -``` +\`\`\` POST /tools/{tool_ref_id}/deploy -``` +\`\`\` -### 前提条件 +### 9. 列出所有工具/Agent -- ACR 中已存在镜像(构建状态为 `ready`) - -### 响应示例 - -```json -{ - "success": true, - "data": { - "tool_ref_id": "agent-agent-weather-agent-ac3c11", - "template_name": "agent_weather_agent_ac3c11", - "image_name": "agnettaiji.azurecr.io/ai-agents/agent-weather-agent-ac3c11:latest", - "status": "deployed", - "message": "模板 agent_weather_agent_ac3c11 已创建,可通过 POST /agents 创建实例" - }, - "message": "Agent 模板已创建,可以开始部署实例" -} -``` - -### 部署实例 - -模板创建后,使用标准的 Agent 创建接口部署实例: - -```bash -curl -X POST http://57.158.136.164/agents \ - -H "Content-Type: application/json" \ - -d '{ - "name": "my-weather-instance", - "template": "agent_weather_agent_ac3c11", - "config": {} - }' -``` - ---- - -## 9. 列出所有工具/Agent - -### 接口 - -``` +\`\`\` GET /tools/?user_id=user-001 -``` - -### 响应示例 - -```json -{ - "success": true, - "data": { - "tools": [ - { - "id": "agent-agent-weather-agent-ac3c11", - "name": "weather-agent", - "type": "agent", - "status": "ready", - "created_at": "2026-01-26T10:00:00.000000" - }, - { - "id": "tool-weather-query-a49e00fe", - "name": "weather-query", - "type": "tool", - "status": "created", - "created_at": "2026-01-26T09:00:00.000000" - } - ], - "count": 2 - } -} -``` +\`\`\` --- @@ -557,10 +432,9 @@ GET /tools/?user_id=user-001 ### 步骤 1: 创建 Agent -```bash -# 发送工具定义,创建 Agent 项目 -curl -X POST http://57.158.136.164/tools/generate-agent \ - -H "Content-Type: application/json" \ +\`\`\`bash +curl -X POST http://20.212.121.126/tools/generate-agent \\ + -H "Content-Type: application/json" \\ -d '{ "agent_name": "my-custom-agent", "description": "我的自定义 Agent", @@ -568,165 +442,73 @@ curl -X POST http://57.158.136.164/tools/generate-agent \ "auto_deploy": true, "tools": [...] }' - -# 返回 agent_ref_id -``` +\`\`\` ### 步骤 2: 等待构建完成 -```bash -# 轮询查询构建状态 -curl http://57.158.136.164/tools/{agent_ref_id}/build-status +\`\`\`bash +curl http://20.212.121.126/tools/{agent_ref_id}/build-status +\`\`\` -# 等待 overall_status 变为 "ready" -``` +### 步骤 3: 访问 Agent -### 步骤 3: 部署 Agent(如果 auto_deploy=false) +\`\`\`bash +# 通过域名访问 +curl http://{agent-name}.taijiagnet.com/ -```bash -# 手动部署 -curl -X POST http://57.158.136.164/tools/{agent_ref_id}/deploy -``` +# 健康检查 +curl http://{agent-name}.taijiagnet.com/health -### 步骤 4: 创建 Agent 实例 +# 调用工具 +curl -X POST http://{agent-name}.taijiagnet.com/tools/call \\ + -H "Content-Type: application/json" \\ + -d '{"tool_name": "get_weather", "parameters": {"q": "Beijing"}}' -```bash -# 使用生成的模板创建实例 -curl -X POST http://57.158.136.164/agents \ - -H "Content-Type: application/json" \ - -d '{ - "name": "my-instance", - "template": "{template_name}", - "config": {} - }' -``` +# MCP 调用 +curl -X POST http://{agent-name}.taijiagnet.com/mcp \\ + -H "Content-Type: application/json" \\ + -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}' +\`\`\` --- -## CI/CD 配置说明 +## Agent 端点说明 -生成的 CI/CD 配置参考 [cicd-AKS](http://gitee.ath.cx:3000/xiaohei/cicd-AKS),包含: +生成的 Agent 提供以下端点: -1. **构建 Docker 镜像** (ARM64 架构) -2. **推送到 ACR** -3. **部署到 AKS**(如果 `auto_deploy=true`) +### REST API -### 生成的 CI/CD 配置示例 +| 端点 | 方法 | 说明 | +|------|------|------| +| \`/\` | GET | 服务信息 | +| \`/health\` | GET | 健康检查 | +| \`/tools\` | GET | 列出可用工具 | +| \`/tools/call\` | POST | 调用工具 | -```yaml -name: CI-CD-AKS +### MCP 端点 -on: - push: - branches: [ main ] - -jobs: - build_and_deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to ACR - uses: docker/login-action@v3 - with: - registry: agnettaiji.azurecr.io - username: ${{ secrets.ACR_USERNAME }} - password: ${{ secrets.ACR_PASSWORD }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/arm64 - push: true - tags: agnettaiji.azurecr.io/ai-agents/{agent-name}:latest - - - name: Azure Login - uses: azure/login@v2 - with: - creds: | - { - "clientId": "${{ secrets.AZ_CLIENT_ID }}", - "clientSecret": "${{ secrets.AZ_CLIENT_SECRET }}", - "subscriptionId": "${{ secrets.AZ_SUBSCRIPTION_ID }}", - "tenantId": "${{ secrets.AZ_TENANT_ID }}" - } - - - name: Get AKS credentials - run: | - az aks get-credentials --resource-group ${{ secrets.AZ_RG }} \ - --name ${{ secrets.AZ_AKS }} --overwrite-existing - - - name: Deploy to AKS - run: | - kubectl set image deployment/{agent-name} \ - {agent-name}=agnettaiji.azurecr.io/ai-agents/{agent-name}:latest \ - -n ai-agents || echo "Deployment will be created by agent-manager" -``` - -### 必需的 Gitee Secrets - -在 Gitee 仓库设置中配置以下 Secrets: - -| Secret 名称 | 说明 | 值 | -|-------------|------|-----| -| `ACR_LOGIN_SERVER` | ACR 登录服务器 | `agnettaiji.azurecr.io` | -| `ACR_USERNAME` | ACR 用户名 | `agnettaiji` | -| `ACR_PASSWORD` | ACR 密码 | `hDpX5t34N5ZmnKdtqyjYL5co/SnXJrmD20CRpGpWaG+ACRCw2wGM` | -| `AZ_CLIENT_ID` | Azure 服务主体 Client ID | `fb306798-2cfe-4ac9-ba48-eab7bc71bcfe` | -| `AZ_CLIENT_SECRET` | Azure 服务主体密钥 | `cVK8Q~xlfBwm2_t2TC24yrTukWV4F3G~eIjBBa0D` | -| `AZ_TENANT_ID` | Azure 租户 ID | `263c3ff6-1be5-4141-8308-b188464fb297` | -| `AZ_SUBSCRIPTION_ID` | Azure 订阅 ID | `c6c47e4c-f5f4-49f8-b26f-7728862c17d6` | -| `AZ_RG` | AKS 资源组名称 | `taiji-ai-pda` | -| `AZ_AKS` | AKS 集群名称 | `taiji-ai-pda` | - -### Gitee Runner 配置 - -Runner 注册 Token: `1A4jRxECdtycHAuzDHcICNeVmLaWrZ9FvwV2vYY2` - -注册命令: -```bash -act_runner register --no-interactive \ - --instance "http://gitee.ath.cx:3000" \ - --token "1A4jRxECdtycHAuzDHcICNeVmLaWrZ9FvwV2vYY2" \ - --name "your-runner-name" \ - --labels "self-hosted,linux,arm64" -``` +| 端点 | 方法 | 说明 | +|------|------|------| +| \`/mcp\` | POST | MCP HTTP 端点 | +| \`/mcp/sse\` | GET | MCP SSE 端点 | +| \`/mcp/sse\` | POST | MCP SSE POST 端点 | --- -## 环境变量配置 +## CI/CD Secrets 配置 -Agent Manager 需要以下环境变量: - -### ConfigMap (agent-manager-config) - -```yaml -GITEE_API_URL: "http://gitee.ath.cx:3000/api/v1" -GITEE_BASE_URL: "http://gitee.ath.cx:3000" -GITEE_OWNER: "xiaohei" -GITEE_TEMPLATE_REPO: "cicd-AKS" -ACR_REGISTRY: "agnettaiji.azurecr.io" -ACR_NAMESPACE: "ai-agents" -LLM_BASE_URL: "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1" -LLM_MODEL: "taiji/claude-sonnet-4-5" -``` - -### Secret (agent-manager-secret) - -```yaml -GITEE_TOKEN: -GITEE_USERNAME: -GITEE_PASSWORD: -LLM_API_KEY: -``` +| Secret 名称 | 说明 | +|-------------|------| +| \`ACR_LOGIN_SERVER\` | ACR 登录服务器 | +| \`ACR_USERNAME\` | ACR 用户名 | +| \`ACR_PASSWORD\` | ACR 密码 | +| \`AZ_CLIENT_ID\` | Azure 服务主体 Client ID | +| \`AZ_CLIENT_SECRET\` | Azure 服务主体密钥 | +| \`AZ_TENANT_ID\` | Azure 租户 ID | +| \`AZ_SUBSCRIPTION_ID\` | Azure 订阅 ID | +| \`AZ_RG\` | AKS 资源组名称 | +| \`AZ_AKS\` | AKS 集群名称 | +| \`AZURE_DNS_ZONE\` | DNS Zone 名称 | --- @@ -735,3 +517,4 @@ LLM_API_KEY: | 日期 | 版本 | 更新内容 | |------|------|----------| | 2026-01-26 | v1.0 | 初始版本 | +| 2026-01-29 | v1.1 | 更新服务地址 (20.212.121.126);添加 DNS 配置说明;添加 generate_tool_code 生成的代码示例;更新项目结构为 src/server/ 目录;添加 expected_domain/expected_namespace 响应字段;添加 Agent 端点说明;简化 Secrets 配置表格 | diff --git a/docs/EXTERNAL_TOOL_API.md b/docs/EXTERNAL_TOOL_API.md new file mode 100644 index 0000000..1a9a1ca --- /dev/null +++ b/docs/EXTERNAL_TOOL_API.md @@ -0,0 +1,741 @@ +# 外部工具 API 文档 + +> **版本**: 2026-01-29 v2.0 +> **服务地址**: http://20.212.121.126 +> **规范参考**: [Agent-Manager外部工具接口规范](http://gitee.ath.cx:3000/xiaohei/taiji-AI-PAD/src/branch/feature/chenchen/Docs/Agent-Manager%E5%A4%96%E9%83%A8%E5%B7%A5%E5%85%B7%E6%8E%A5%E5%8F%A3%E8%A7%84%E8%8C%83.md) + +--- + +## 📊 系统架构 + +``` +┌─────────────────────────────────────────────────────────────────────────────────────┐ +│ 系统交互流程 │ +├─────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ 前端 │ ───→ │ MCP-Server │ ───→ │ Agent Manager │ │ +│ │ 用户界面 │ │ (调用方) │ │ (本API规范) │ │ +│ └─────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ │ │ +│ ↓ ↓ │ +│ ┌─────────────┐ ┌─────────────────┐ │ +│ │ PostgreSQL │ │ 工具文件存储 │ │ +│ │ (基本信息) │ │ AKS 部署 │ │ +│ └─────────────┘ └─────────────────┘ │ +│ │ │ +│ ↓ │ +│ ┌─────────────────┐ │ +│ │ 计费回调系统 │ │ +│ │ (运行时长记录) │ │ +│ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 职责划分 + +| 组件 | 职责 | +|------|------| +| **MCP-Server** | 接收前端请求、存储工具基本信息和 tool_ref_id、调用 Agent Manager 接口 | +| **Agent Manager** | 生成 Pydantic 工具代码文件、存储完整配置(含敏感信息)、部署 Agent 到 AKS、集成计费回调 | +| **计费系统** | 接收 Agent 运行时长回调、记录使用的工具、计算费用 | + +--- + +## 🆕 v2.0 新增功能 + +### 1. 回调功能(计费) + +每个生成的 Agent 自动包含回调模块,用于向计费系统发送使用记录: + +- 记录 Agent 运行时长 +- 记录使用的工具列表 +- 支持请求级别的计费追踪 + +### 2. 多工具支持 + +每个 Agent 可以包含**多个工具**,在创建时通过 `tool_refs` 数组指定。 + +### 3. 新增状态查询 API + +- `GET /agents/{agent_ref_id}/build-status` - 查询 CI/CD 构建状态 +- `GET /agents/{agent_ref_id}/deployment-info` - 查询部署详情 + +### 4. 批量工具调用 + +- `POST /tools/batch-call` - 批量调用多个工具(统一计费) + +--- + +## 🔐 通用规范 + +### 基础路径 + +``` +http://20.212.121.126/external-tools +``` + +### 请求头 + +```http +Content-Type: application/json +X-User-ID: # 可选,用于计费 +``` + +### 响应格式 + +#### 成功响应 + +```json +{ + "success": true, + "data": { ... }, + "message": "操作成功" +} +``` + +#### 错误响应 + +```json +{ + "success": false, + "error": "error_code", + "message": "错误描述" +} +``` + +--- + +## 📑 接口列表 + +| 序号 | 接口 | 方法 | 说明 | +|------|------|------|------| +| 1 | `/external-tools/generate` | POST | 生成外部数据工具 | +| 2 | `/external-tools/{tool_ref_id}` | GET | 获取工具详情 | +| 3 | `/external-tools/{tool_ref_id}` | PUT | 更新外部数据工具 | +| 4 | `/external-tools/{tool_ref_id}` | DELETE | 删除外部数据工具 | +| 5 | `/external-tools/{tool_ref_id}/test` | POST | 测试工具连接 | +| 6 | `/external-tools/{tool_ref_id}/code` | GET | 获取生成的代码 | +| 7 | `/external-tools/` | GET | 列出所有工具 | +| 8 | `/external-tools/agents/create-with-tools` | POST | 创建带工具的 Agent | +| 9 | `/external-tools/agents/{agent_ref_id}/build-status` | GET | 🆕 查询构建状态 | +| 10 | `/external-tools/agents/{agent_ref_id}/deployment-info` | GET | 🆕 查询部署信息 | +| 11 | `/agents` | POST | 创建 Agent(支持 tool_refs 字段) | + +--- + +## 1️⃣ 生成外部数据工具 + +### 接口 + +``` +POST /external-tools/generate +``` + +### 功能描述 + +MCP-Server 将用户配置的工具信息发送给 Agent Manager,Agent Manager 需要: +1. 验证配置格式 +2. 根据配置生成 Pydantic AI 工具代码文件 +3. 存储工具代码文件和完整配置(包含敏感信息如 API Key) +4. 返回唯一的 `tool_ref_id` 供后续引用 + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | ✅ | 工具名称(1-100 字符,将用于生成 Python 函数名) | +| description | string | ✅ | 工具描述(将作为工具的 docstring) | +| url | string | ✅ | API 端点 URL | +| method | string | ✅ | HTTP 方法:GET/POST/PUT/DELETE/PATCH | +| user_id | string | ✅ | 用户 ID(UUID 格式) | +| tenant_id | string | ❌ | 租户 ID(UUID 格式) | +| headers | object | ❌ | 自定义请求头 | +| auth | object | ❌ | 认证配置(详见下方) | +| request_params | object | ❌ | URL 查询参数定义(JSON Schema 格式) | +| request_body | object | ❌ | 请求体定义(JSON Schema 格式) | +| response_mapping | object | ❌ | 响应字段映射 | +| timeout | integer | ❌ | 超时时间(秒),默认 30 | +| retry | object | ❌ | 重试配置 | + +### 认证配置 (auth) 结构 + +#### API Key 认证 + +```json +{ + "type": "api_key", + "key": "sk-xxxxxxxxxxxx", + "in": "header", // 位置: header / query + "name": "X-API-Key" // 参数名 +} +``` + +#### Bearer Token 认证 + +```json +{ + "type": "bearer", + "key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +#### Basic Auth 认证 + +```json +{ + "type": "basic", + "username": "admin", + "password": "password123" +} +``` + +### 请求示例 + +```bash +curl -X POST http://20.212.121.126/external-tools/generate \ + -H "Content-Type: application/json" \ + -d '{ + "name": "weather-query", + "description": "查询指定城市的天气信息", + "url": "https://api.weather.com/v1/current", + "method": "GET", + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "auth": { + "type": "api_key", + "key": "sk-xxx", + "in": "query", + "name": "apikey" + }, + "request_params": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名称" + } + } + } + }' +``` + +### 响应示例 + +```json +{ + "success": true, + "data": { + "tool_ref_id": "tool-weather-query-7ff71b5e", + "name": "weather-query", + "description": "查询指定城市的天气信息", + "created_at": "2026-01-29T11:07:32.538533" + }, + "message": "工具生成成功" +} +``` + +--- + +## 2️⃣ 更新外部数据工具 + +### 接口 + +``` +PUT /external-tools/{tool_ref_id} +``` + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| description | string | ❌ | 工具描述 | +| url | string | ❌ | API URL | +| method | string | ❌ | HTTP 方法 | +| headers | object | ❌ | 自定义请求头 | +| auth | object | ❌ | 认证配置 | +| request_params | object | ❌ | 请求参数定义 | +| request_body | object | ❌ | 请求体定义 | +| timeout | integer | ❌ | 超时时间 | + +### 请求示例 + +```bash +curl -X PUT http://20.212.121.126/external-tools/tool-weather-query-7ff71b5e \ + -H "Content-Type: application/json" \ + -d '{ + "description": "更新后的天气查询工具描述", + "timeout": 60 + }' +``` + +### 响应示例 + +```json +{ + "success": true, + "data": { + "tool_ref_id": "tool-weather-query-7ff71b5e", + "name": "weather-query", + "updated_at": "2026-01-29T11:09:12.322545" + }, + "message": "工具更新成功" +} +``` + +--- + +## 3️⃣ 删除外部数据工具 + +### 接口 + +``` +DELETE /external-tools/{tool_ref_id} +``` + +### 请求示例 + +```bash +curl -X DELETE http://20.212.121.126/external-tools/tool-weather-query-7ff71b5e +``` + +### 响应示例 + +```json +{ + "success": true, + "data": { + "tool_ref_id": "tool-weather-query-7ff71b5e" + }, + "message": "工具删除成功" +} +``` + +### 注意事项 + +- 如果工具正在被 Agent 使用,删除将失败并返回 `tool_in_use` 错误 + +--- + +## 4️⃣ 测试工具连接 + +### 接口 + +``` +POST /external-tools/{tool_ref_id}/test +``` + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| test_params | object | ❌ | 测试参数(URL 查询参数) | +| test_body | object | ❌ | 测试请求体 | + +### 请求示例 + +```bash +curl -X POST http://20.212.121.126/external-tools/tool-weather-query-7ff71b5e/test \ + -H "Content-Type: application/json" \ + -d '{"test_params": {"city": "Beijing"}}' +``` + +### 响应示例 + +```json +{ + "success": true, + "data": { + "status_code": 200, + "response_time_ms": 1058.84, + "content_type": "application/json", + "response_preview": "{...}" + }, + "message": "连接测试成功" +} +``` + +--- + +## 5️⃣ 创建带有外部工具的 Agent(支持多工具) + +### 接口 + +``` +POST /external-tools/agents/create-with-tools +``` + +### 功能描述 + +创建带有外部工具的 Agent,自动: +1. 加载对应的工具代码文件(**支持多个工具**) +2. 将工具集成到 Agent 中 +3. 生成完整项目并推送到 Gitee +4. 触发 CI/CD 构建和部署 +5. **集成计费回调功能** + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | ✅ | Agent 名称(1-63 字符,符合 K8s 命名规范) | +| template | string | ✅ | Agent 模板名称 | +| tool_refs | string[] | ✅ | 外部数据工具标识列表(**支持多个**) | +| config | object | ❌ | 资源配置 | +| env | object | ❌ | 环境变量 | + +### 请求示例(多工具) + +```bash +curl -X POST http://20.212.121.126/external-tools/agents/create-with-tools \ + -H "Content-Type: application/json" \ + -d '{ + "name": "multi-tool-agent", + "template": "custom_agent", + "tool_refs": [ + "tool-search-api-60e5c2a1", + "tool-weather-api-8e08be59", + "tool-translate-api-82e0f9bd" + ], + "config": { + "user_id": "550e8400-e29b-41d4-a716-446655440000" + } + }' +``` + +### 响应示例 + +```json +{ + "success": true, + "name": "multi-tool-agent", + "agent_ref_id": "agent-agent-multi-tool-agent-ad2b92", + "namespace": "agent-agent-multi-tool-agent-ad2b92", + "status": "Building", + "created_at": "2026-01-29T11:45:30.123456", + "template": "custom_agent", + "service_port": 8000, + "access_info": { + "domain": "agent-multi-tool-agent-ad2b92.taijiagnet.com", + "domain_url": "http://agent-multi-tool-agent-ad2b92.taijiagnet.com", + "ip_url": "pending" + }, + "tools_attached": 3, + "repo_url": "http://gitee.ath.cx:3000/zhanggangyong/agent-multi-tool-agent-ad2b92", + "image_name": "agnettaiji.azurecr.io/ai-agents/agent-multi-tool-agent-ad2b92:latest", + "status_query_url": "/external-tools/agents/agent-agent-multi-tool-agent-ad2b92/build-status", + "deployment_info_url": "/external-tools/agents/agent-agent-multi-tool-agent-ad2b92/deployment-info" +} +``` + +--- + +## 6️⃣ 🆕 查询 CI/CD 构建状态 + +### 接口 + +``` +GET /external-tools/agents/{agent_ref_id}/build-status +``` + +### 功能描述 + +查询 Agent 的 CI/CD 构建状态,包括: +- Gitee Action 运行状态 +- ACR 镜像是否已构建 +- 整体构建状态 + +### 响应示例 + +```json +{ + "success": true, + "data": { + "agent_ref_id": "agent-agent-multi-tool-agent-ad2b92", + "repo_name": "agent-multi-tool-agent-ad2b92", + "overall_status": "ready", + "gitee_action": { + "status": "completed", + "conclusion": "success", + "run_id": 123, + "html_url": "http://gitee.ath.cx:3000/.../actions/runs/123" + }, + "acr_image": { + "image_name": "agnettaiji.azurecr.io/ai-agents/agent-multi-tool-agent-ad2b92:latest", + "exists": true, + "tags": ["latest", "abc123"] + }, + "access_info": { + "expected_domain": "agent-multi-tool-agent-ad2b92.taijiagnet.com", + "expected_url": "http://agent-multi-tool-agent-ad2b92.taijiagnet.com", + "expected_namespace": "agent-agent-multi-tool-agent-ad2b92" + } + } +} +``` + +### 状态说明 + +| overall_status | 含义 | +|----------------|------| +| `pending` | 等待 CI/CD 开始 | +| `building` | CI/CD 正在构建 | +| `ready` | 镜像已构建,等待部署 | +| `deployed` | 已部署到 K8s | +| `build_failed` | 构建失败 | +| `unknown` | 状态未知 | + +--- + +## 7️⃣ 🆕 查询部署详情 + +### 接口 + +``` +GET /external-tools/agents/{agent_ref_id}/deployment-info +``` + +### 功能描述 + +查询 Agent 部署后的详细信息,包括: +- K8s 部署状态 +- Pod 运行状态 +- 服务端点信息 +- DNS 域名信息 + +### 响应示例 + +```json +{ + "success": true, + "data": { + "agent_ref_id": "agent-agent-multi-tool-agent-ad2b92", + "repo_name": "agent-multi-tool-agent-ad2b92", + "namespace": "agent-agent-multi-tool-agent-ad2b92", + "k8s_status": { + "namespace_exists": true, + "namespace_status": "Active", + "pod_name": "agent-multi-tool-agent-ad2b92-xxx", + "pod_status": "Running", + "pod_ip": "10.224.0.50", + "node_name": "aks-nodepool1-xxx", + "status": "running" + }, + "access_info": { + "service_name": "agent-multi-tool-agent-ad2b92-service", + "cluster_ip": "10.0.100.50", + "external_ip": "20.x.x.x", + "ip_url": "http://20.x.x.x", + "domain": "agent-multi-tool-agent-ad2b92.taijiagnet.com", + "domain_url": "http://agent-multi-tool-agent-ad2b92.taijiagnet.com", + "recommended_url": "http://agent-multi-tool-agent-ad2b92.taijiagnet.com" + }, + "image_info": { + "image_name": "agnettaiji.azurecr.io/ai-agents/agent-multi-tool-agent-ad2b92:latest", + "registry": "agnettaiji.azurecr.io" + } + } +} +``` + +--- + +## 8️⃣ 原有 /agents 接口 - 支持 tool_refs + +### 接口 + +``` +POST /agents +``` + +### 新增参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| tool_refs | string[] | ❌ | 外部数据工具标识列表(支持多个) | + +### 请求示例 + +```bash +curl -X POST http://20.212.121.126/agents \ + -H "Content-Type: application/json" \ + -d '{ + "name": "test-tools-agent", + "template": "echo_agent", + "framework": "API", + "config": { + "user_id": "test-user" + }, + "tool_refs": ["tool-weather-query-7ff71b5e", "tool-stock-api-abc123"] + }' +``` + +### 响应新增字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| tools_attached | integer | 附加的外部工具数量 | + +--- + +## 🔔 回调功能(计费) + +### 概述 + +每个生成的 Agent 自动集成回调功能,用于向计费系统发送使用记录。 + +### 生成的文件结构 + +``` +src/server/ +├── mcp_server.py # MCP 服务器(工具定义) +├── api_server.py # HTTP API(集成回调) +└── agent_callback_utils.py # 回调工具模块 +``` + +### 回调数据格式 + +Agent 在处理请求时会自动发送以下数据到计费系统: + +```json +{ + "agentName": "multi-tool-agent", + "userId": "550e8400-e29b-41d4-a716-446655440000", + "podRunningTimeSeconds": 5, + "toolsUsed": ["search-api", "weather-api"], + "startTime": "2026-01-29T11:45:30.000000+00:00", + "endTime": "2026-01-29T11:45:35.000000+00:00", + "requestId": "api-1738151130" +} +``` + +### 回调触发时机 + +- 每次 `/tools/call` 调用 +- 每次 `/tools/batch-call` 调用 +- 每次 MCP `tools/call` 请求 + +### 传递 User ID + +通过以下方式传递 `user_id` 用于计费: + +1. **请求参数**:在请求体中添加 `user_id` 字段 +2. **请求头**:设置 `X-User-ID` 头 +3. **环境变量**:Agent 启动时设置 `USER_ID` 环境变量 + +--- + +## ❌ 错误码定义 + +| HTTP 状态码 | 错误代码 | 说明 | +|------------|----------|------| +| 400 | invalid_config | 配置格式无效 | +| 400 | invalid_url | URL 格式无效 | +| 400 | invalid_method | HTTP 方法无效 | +| 400 | invalid_auth | 认证配置无效 | +| 400 | invalid_schema | JSON Schema 格式无效 | +| 404 | tool_not_found | 工具不存在 | +| 404 | agent_not_found | Agent 不存在 | +| 409 | tool_name_exists | 工具名称已存在(同一用户下) | +| 409 | tool_in_use | 工具正在被 Agent 使用 | +| 500 | generation_failed | 工具代码生成失败 | +| 500 | deployment_failed | Agent 部署失败 | +| 500 | repo_creation_failed | Gitee 仓库创建失败 | + +--- + +## 📋 完整使用流程示例 + +### 1. 创建多个工具 + +```bash +# 工具1: 搜索 API +TOOL1=$(curl -s -X POST http://20.212.121.126/external-tools/generate \ + -H "Content-Type: application/json" \ + -d '{ + "name": "search-api", + "description": "搜索API工具", + "url": "https://api.search.com/v1/search", + "method": "GET", + "user_id": "user-123" + }' | jq -r '.data.tool_ref_id') + +# 工具2: 天气 API +TOOL2=$(curl -s -X POST http://20.212.121.126/external-tools/generate \ + -H "Content-Type: application/json" \ + -d '{ + "name": "weather-api", + "description": "获取天气信息", + "url": "https://api.weather.com/v1/current", + "method": "GET", + "user_id": "user-123" + }' | jq -r '.data.tool_ref_id') + +echo "工具1: $TOOL1" +echo "工具2: $TOOL2" +``` + +### 2. 创建包含多工具的 Agent + +```bash +AGENT_RESULT=$(curl -s -X POST http://20.212.121.126/external-tools/agents/create-with-tools \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"my-multi-tool-agent\", + \"template\": \"custom_agent\", + \"tool_refs\": [\"$TOOL1\", \"$TOOL2\"], + \"config\": {\"user_id\": \"user-123\"} + }") + +AGENT_REF_ID=$(echo $AGENT_RESULT | jq -r '.agent_ref_id') +echo "Agent Ref ID: $AGENT_REF_ID" +``` + +### 3. 轮询构建状态 + +```bash +while true; do + STATUS=$(curl -s "http://20.212.121.126/external-tools/agents/${AGENT_REF_ID}/build-status") + OVERALL=$(echo $STATUS | jq -r '.data.overall_status') + echo "当前状态: $OVERALL" + + if [ "$OVERALL" = "ready" ] || [ "$OVERALL" = "deployed" ]; then + echo "✅ 构建完成!" + break + elif [ "$OVERALL" = "build_failed" ]; then + echo "❌ 构建失败!" + break + fi + sleep 30 +done +``` + +### 4. 获取部署信息 + +```bash +curl -s "http://20.212.121.126/external-tools/agents/${AGENT_REF_ID}/deployment-info" | jq . +``` + +### 5. 访问 Agent + +```bash +DOMAIN=$(curl -s "http://20.212.121.126/external-tools/agents/${AGENT_REF_ID}/deployment-info" \ + | jq -r '.data.access_info.domain') + +curl "http://${DOMAIN}/" +``` + +--- + +## 📞 联系方式 + +如有疑问,请联系 Agent Manager 开发团队。 + +--- + +**文档更新记录** + +| 日期 | 版本 | 更新内容 | +|------|------|----------| +| 2026-01-29 | v1.0 | 初始版本,实现 MCP-Server 外部工具接口规范 | +| 2026-01-29 | v2.0 | 新增回调功能(计费)、多工具支持、构建状态查询、部署信息查询 | diff --git a/external_tool_api.py b/external_tool_api.py new file mode 100644 index 0000000..47f9ca4 --- /dev/null +++ b/external_tool_api.py @@ -0,0 +1,1025 @@ +""" +外部工具 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") + 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 + + +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 格式)") + response_mapping: Optional[Dict] = Field(None, description="响应字段映射") + timeout: int = Field(30, description="超时时间(秒),默认 30") + retry: Optional[RetryConfig] = Field(None, description="重试配置") + + +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 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="环境变量") + + +# ==================== 工具 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]}" + + # 构建工具配置 + tool_config = { + "name": request.name, + "description": request.description, + "url": request.url, + "method": request.method.upper(), + "headers": request.headers, + "auth": request.auth.model_dump(by_alias=True) if request.auth else None, + "request_params": request.request_params, + "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 工具代码 + 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.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") + key_value = auth.get("key", "") + + if location == "header": + headers[key_name] = key_value + elif location == "query": + params[key_name] = key_value + + elif auth_type == "bearer": + headers["Authorization"] = f"Bearer {auth.get('key', '')}" + + 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", {}) + 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) + }) + + # 生成仓库名 + repo_name = f"agent-{request.name.lower().replace('_', '-')}-{uuid.uuid4().hex[:6]}" + agent_ref_id = f"agent-{repo_name}" + + # 生成完整项目文件 + project_files = agent_code_generator.generate_full_project( + agent_name=request.name, + description=f"Agent with {len(tools)} external tools", + tools_config=tools_config, + auto_deploy=True + ) + + # 创建 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) + + # 推送文件 + 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 + ) + + # 设置 CI/CD Secrets + cicd_secrets = { + "ACR_LOGIN_SERVER": "agnettaiji.azurecr.io", + "ACR_USERNAME": "agnettaiji", + "ACR_PASSWORD": os.getenv("ACR_PASSWORD", ""), + "AZ_CLIENT_ID": os.getenv("AZ_CLIENT_ID", ""), + "AZ_CLIENT_SECRET": os.getenv("AZ_CLIENT_SECRET", ""), + "AZ_TENANT_ID": os.getenv("AZ_TENANT_ID", ""), + "AZ_SUBSCRIPTION_ID": os.getenv("AZ_SUBSCRIPTION_ID", ""), + "AZ_RG": os.getenv("AZ_RG", "taiji-ai-pda"), + "AZ_AKS": os.getenv("AZ_AKS", "taiji-ai-pda"), + "AZURE_DNS_ZONE": os.getenv("AZURE_DNS_ZONE", "taijiagnet.com") + } + + gitee_manager.set_repo_secrets( + repo_name=repo_name, + secrets=cicd_secrets, + owner=repo_owner + ) + + # 标记工具被使用 + for ref in request.tool_refs: + tool_storage.mark_tool_in_use(ref, request.name) + + # 计算 K8s 相关名称 + k8s_name = repo_name.lower().replace("_", "-").replace(" ", "-") + expected_domain = f"{k8s_name}.taijiagnet.com" + agent_ref_id = f"agent-{repo_name}" + + # 存储 Agent 信息以便后续查询 + AGENT_REFS[agent_ref_id] = { + "agent_ref_id": agent_ref_id, + "name": request.name, + "repo_name": repo_name, + "repo_url": repo_result.get("html_url"), + "repo_owner": repo_owner, + "namespace": f"agent-{k8s_name}", + "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": f"agent-{k8s_name}", + "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" + }, + "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 = repo_name.lower().replace("_", "-").replace(" ", "-") + expected_domain = f"{k8s_name}.taijiagnet.com" + + 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": f"agent-{k8s_name}" + } + } + } + + 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_ref_id 推断 repo_name + 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)} diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..c750ace --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,414 @@ +# Agent Manager Kubernetes部署文档 + +## 概述 + +本文档说明如何在Azure Kubernetes Service (AKS)上部署Agent Manager服务。 + +## 前提条件 + +1. **Azure资源** + - Azure订阅 + - AKS集群 + - Azure Container Registry (ACR) + - Azure DNS Zone(用于自动配置域名) + +2. **本地工具** + - `kubectl` (Kubernetes命令行工具) + - `az` (Azure CLI) + - Docker (用于构建镜像) + +3. **权限要求** + - AKS集群的管理员权限 + - ACR的推送权限 + - DNS Zone的管理权限 + +## 部署步骤 + +### 1. 配置Azure凭据 + +#### 1.1 创建Service Principal(如果还没有) + +```bash +# 创建Service Principal +az ad sp create-for-rbac \ + --name "agent-manager-sp" \ + --role contributor \ + --scopes /subscriptions/{subscription-id} + +# 输出示例: +# { +# "appId": "c5ba26db-f180-425f-bac3-93708d853988", +# "displayName": "agent-manager-sp", +# "password": "ydt8Q~...", +# "tenant": "263c3ff6-1be5-4141-8308-b188464fb297" +# } +``` + +#### 1.2 配置DNS权限(重要!⚠️) + +**Agent Manager需要DNS Zone Contributor权限才能为创建的agent自动配置域名。** + +使用提供的脚本配置DNS权限: + +```bash +# 方法1:使用自动化脚本(推荐) +bash scripts/setup_dns_permissions.sh + +# 方法2:手动配置 +AZURE_CLIENT_ID="your-service-principal-app-id" +AZURE_SUBSCRIPTION_ID="your-subscription-id" +AZURE_RESOURCE_GROUP="your-resource-group" +AZURE_DNS_ZONE="your-dns-zone.com" + +DNS_ZONE_ID="/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.Network/dnsZones/$AZURE_DNS_ZONE" + +az role assignment create \ + --assignee $AZURE_CLIENT_ID \ + --role "DNS Zone Contributor" \ + --scope $DNS_ZONE_ID +``` + +验证权限: +```bash +az role assignment list \ + --assignee $AZURE_CLIENT_ID \ + --scope $DNS_ZONE_ID \ + --output table +``` + +#### 1.3 更新Kubernetes Secret + +编辑 `k8s/agent-manager-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: agent-manager-secret + namespace: agent-manager +type: Opaque +stringData: + AZURE_TENANT_ID: "263c3ff6-1be5-4141-8308-b188464fb297" + AZURE_CLIENT_ID: "c5ba26db-f180-425f-bac3-93708d853988" + AZURE_CLIENT_SECRET: "your-client-secret" + AZURE_SUBSCRIPTION_ID: "45d7a360-af09-40fc-9afc-56dc475245ec" + AZURE_RESOURCE_GROUP: "taiji-ai-v0" + AZURE_DNS_ZONE: "taijiagnet.com" +``` + +### 2. 配置ACR访问 + +创建ACR secret: + +```bash +# 获取ACR登录服务器 +ACR_NAME="your-acr-name" +ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io" + +# 创建Docker registry secret +kubectl create secret docker-registry acr-secret \ + --namespace agent-manager \ + --docker-server=$ACR_LOGIN_SERVER \ + --docker-username=$AZURE_CLIENT_ID \ + --docker-password=$AZURE_CLIENT_SECRET +``` + +或使用脚本: +```bash +bash k8s/create-acr-secret.sh +``` + +### 3. 构建和推送镜像 + +```bash +# 构建镜像 +docker build -t $ACR_LOGIN_SERVER/agent-manager:latest . + +# 登录ACR +az acr login --name $ACR_NAME + +# 推送镜像 +docker push $ACR_LOGIN_SERVER/agent-manager:latest +``` + +### 4. 部署到Kubernetes + +```bash +# 创建命名空间 +kubectl apply -f k8s/agent-manager-namespace.yaml + +# 创建RBAC(ServiceAccount、Role、RoleBinding) +kubectl apply -f k8s/agent-manager-rbac.yaml + +# 创建Secret(Azure凭据) +kubectl apply -f k8s/agent-manager-secret.yaml + +# 创建ACR Secret +kubectl apply -f k8s/acr-secret.yaml + +# 创建ConfigMap(可选) +kubectl apply -f k8s/agent-manager-configmap.yaml + +# 创建Deployment +kubectl apply -f k8s/agent-manager-deployment.yaml + +# 创建Service(LoadBalancer) +kubectl apply -f k8s/agent-manager-service.yaml +``` + +或使用一键部署脚本: +```bash +bash k8s/deploy.sh +``` + +### 5. 验证部署 + +```bash +# 检查Pod状态 +kubectl get pods -n agent-manager + +# 检查Service和外网IP +kubectl get svc -n agent-manager + +# 查看日志 +kubectl logs -n agent-manager deployment/agent-manager + +# 测试健康检查 +AGENT_MANAGER_IP=$(kubectl get svc agent-manager -n agent-manager -o jsonpath='{.status.loadBalancer.ingress[0].ip}') +curl http://$AGENT_MANAGER_IP/ +``` + +### 6. 测试Agent创建 + +```bash +# 创建测试agent +curl -X POST http://$AGENT_MANAGER_IP/agents \ + -H "Content-Type: application/json" \ + -d '{ + "name": "test-agent", + "template": "echo_agent", + "framework": "API", + "config": { + "user_id": "test-user" + } + }' + +# 检查返回结果应包含: +# - external_ip: 外网IP地址 +# - domain: 自动配置的域名 (test-agent.taijiagnet.com) +# - domain_url: 域名访问地址 +# - recommended: 推荐访问地址(域名) + +# 查看agent状态 +curl http://$AGENT_MANAGER_IP/agents/test-agent/status + +# 测试域名访问 +curl http://test-agent.taijiagnet.com/ + +# 清理测试agent +curl -X DELETE http://$AGENT_MANAGER_IP/agents/test-agent +``` + +## 目录结构 + +``` +k8s/ +├── README.md # 本文档 +├── agent-manager-namespace.yaml # Namespace定义 +├── agent-manager-rbac.yaml # RBAC配置(ServiceAccount、Role等) +├── agent-manager-secret.yaml # Azure凭据Secret +├── agent-manager-configmap.yaml # 配置文件ConfigMap +├── agent-manager-deployment.yaml # Deployment定义 +├── agent-manager-service.yaml # LoadBalancer Service定义 +├── acr-secret.yaml # ACR访问Secret +├── create-acr-secret.sh # 创建ACR Secret脚本 +└── deploy.sh # 一键部署脚本 +``` + +## 常见问题 + +### Q1: Agent创建后没有返回域名信息 + +**症状:** 创建agent时返回外网IP但没有`domain`字段。 + +**原因:** Service Principal缺少DNS Zone的写权限。 + +**解决方案:** +```bash +# 运行DNS权限配置脚本 +bash scripts/setup_dns_permissions.sh + +# 或手动分配权限(见上文"配置DNS权限"部分) +``` + +详细信息见:`docs/DNS_ISSUE_FIX_REPORT.md` + +### Q2: Pod启动失败,提示ImagePullBackOff + +**原因:** 无法从ACR拉取镜像。 + +**解决方案:** +1. 检查ACR secret是否正确创建 +2. 验证Service Principal有ACR的pull权限 +3. 确认镜像名称和标签正确 + +```bash +# 检查ACR secret +kubectl get secret acr-secret -n agent-manager + +# 重新创建ACR secret +bash k8s/create-acr-secret.sh +``` + +### Q3: LoadBalancer IP一直处于Pending状态 + +**原因:** AKS集群配置或云提供商问题。 + +**解决方案:** +1. 检查AKS集群是否支持LoadBalancer +2. 查看Service事件:`kubectl describe svc agent-manager -n agent-manager` +3. 确认Azure订阅有足够的配额 + +### Q4: 如何更新部署 + +```bash +# 方法1:修改YAML文件后重新应用 +kubectl apply -f k8s/agent-manager-deployment.yaml + +# 方法2:更新镜像 +kubectl set image deployment/agent-manager \ + agent-manager=your-acr.azurecr.io/agent-manager:new-tag \ + -n agent-manager + +# 方法3:编辑Deployment +kubectl edit deployment agent-manager -n agent-manager + +# 查看滚动更新状态 +kubectl rollout status deployment/agent-manager -n agent-manager +``` + +### Q5: 如何查看日志 + +```bash +# 查看所有Pod日志 +kubectl logs -n agent-manager -l app=agent-manager + +# 查看特定Pod日志 +kubectl logs -n agent-manager + +# 实时跟踪日志 +kubectl logs -n agent-manager -l app=agent-manager -f + +# 查看前一个容器的日志(如果Pod重启过) +kubectl logs -n agent-manager --previous +``` + +## 监控和维护 + +### 资源使用 + +```bash +# 查看Pod资源使用 +kubectl top pods -n agent-manager + +# 查看Node资源使用 +kubectl top nodes +``` + +### 扩缩容 + +```bash +# 手动扩容 +kubectl scale deployment agent-manager \ + --replicas=3 \ + -n agent-manager + +# 自动扩缩容(HPA) +kubectl autoscale deployment agent-manager \ + --cpu-percent=80 \ + --min=2 \ + --max=10 \ + -n agent-manager +``` + +### 健康检查 + +Agent Manager提供以下健康检查端点: + +- `GET /` - 基本健康检查 +- `GET /templates` - 模板列表(验证数据库连接) +- `GET /agents` - Agent列表(验证K8s连接) + +## 安全最佳实践 + +1. **Secret管理** + - 不要将Secret提交到版本控制 + - 使用Azure Key Vault或Kubernetes Secrets加密 + - 定期轮换凭据 + +2. **RBAC** + - 使用最小权限原则 + - 为不同环境使用不同的Service Principal + - 定期审计权限分配 + +3. **网络安全** + - 考虑使用Private LoadBalancer + - 配置Network Policy限制Pod间通信 + - 使用Ingress Controller配置TLS + +4. **镜像安全** + - 定期扫描镜像漏洞 + - 使用最新的基础镜像 + - 不要在镜像中包含敏感信息 + +## 故障排查 + +### 诊断命令 + +```bash +# 检查所有资源 +kubectl get all -n agent-manager + +# 查看Pod详情 +kubectl describe pod -n agent-manager + +# 查看事件 +kubectl get events -n agent-manager --sort-by='.lastTimestamp' + +# 检查ServiceAccount +kubectl get sa -n agent-manager +kubectl describe sa agent-manager-sa -n agent-manager + +# 检查RoleBinding +kubectl get rolebinding -n agent-manager +kubectl describe rolebinding agent-manager-role-binding -n agent-manager + +# 进入Pod调试 +kubectl exec -it -n agent-manager -- /bin/bash +``` + +### 日志级别 + +在Deployment中设置环境变量调整日志级别: + +```yaml +env: +- name: LOG_LEVEL + value: "DEBUG" # DEBUG, INFO, WARNING, ERROR +``` + +## 参考资料 + +- [Kubernetes官方文档](https://kubernetes.io/docs/) +- [Azure Kubernetes Service文档](https://docs.microsoft.com/azure/aks/) +- [Azure DNS文档](https://docs.microsoft.com/azure/dns/) +- [Agent Manager API文档](../docs/API_DOCUMENTATION.md) +- [DNS问题修复报告](../docs/DNS_ISSUE_FIX_REPORT.md) + +## 联系支持 + +如有问题,请: +1. 查看本文档的常见问题部分 +2. 查看`docs/DNS_ISSUE_FIX_REPORT.md` +3. 查看agent-manager日志 +4. 联系开发团队 diff --git a/k8s/agent-manager-deployment.yaml b/k8s/agent-manager-deployment.yaml index b0afbe6..13c48fe 100644 --- a/k8s/agent-manager-deployment.yaml +++ b/k8s/agent-manager-deployment.yaml @@ -31,7 +31,7 @@ spec: containers: - name: agent-manager - image: agnettaiji.azurecr.io/agent-manager:latest-arm64 + image: agnettaiji.azurecr.io/ai-agents/agent-manager:latest imagePullPolicy: Always ports: diff --git a/scripts/redeploy-agent-manager-with-kubeconfig.sh b/scripts/redeploy-agent-manager-with-kubeconfig.sh new file mode 100755 index 0000000..4d622ef --- /dev/null +++ b/scripts/redeploy-agent-manager-with-kubeconfig.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Non-destructive redeploy for agent-manager to a target cluster via kubeconfig/context. +# - Applies: namespace, RBAC, deployment, service +# - Does NOT apply configmap/secret by default (to avoid overwriting real credentials) +# +# Usage: +# ./scripts/redeploy-agent-manager-with-kubeconfig.sh /path/to/kubeconfig [context] +# +# Optional env: +# APPLY_CONFIG=1 -> also apply k8s/agent-manager-configmap.yaml +# APPLY_SECRET=1 -> also apply k8s/agent-manager-secret.yaml (DANGEROUS unless you edited it) + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +K8S_DIR="${ROOT_DIR}/k8s" + +KUBECONFIG_FILE="${1:-}" +CONTEXT="${2:-}" + +if [[ -z "${KUBECONFIG_FILE}" ]]; then + echo "ERROR: kubeconfig path required." + echo "Usage: $0 /path/to/kubeconfig [context]" + exit 1 +fi + +if [[ ! -f "${KUBECONFIG_FILE}" ]]; then + echo "ERROR: kubeconfig not found: ${KUBECONFIG_FILE}" + exit 1 +fi + +export KUBECONFIG="${KUBECONFIG_FILE}" + +echo "Using kubeconfig: ${KUBECONFIG}" +if [[ -n "${CONTEXT}" ]]; then + echo "Switching context: ${CONTEXT}" + kubectl config use-context "${CONTEXT}" >/dev/null +fi + +echo "Target cluster:" +kubectl cluster-info + +echo "Applying namespace..." +kubectl apply -f "${K8S_DIR}/agent-manager-namespace.yaml" + +if [[ "${APPLY_CONFIG:-0}" == "1" ]]; then + echo "Applying configmap (APPLY_CONFIG=1)..." + kubectl apply -f "${K8S_DIR}/agent-manager-configmap.yaml" +else + echo "Skipping configmap (set APPLY_CONFIG=1 to apply)." +fi + +if [[ "${APPLY_SECRET:-0}" == "1" ]]; then + echo "Applying secret (APPLY_SECRET=1)..." + kubectl apply -f "${K8S_DIR}/agent-manager-secret.yaml" +else + echo "Skipping secret (set APPLY_SECRET=1 to apply)." +fi + +echo "Applying RBAC..." +kubectl apply -f "${K8S_DIR}/agent-manager-rbac.yaml" + +echo "Applying deployment + service..." +kubectl apply -f "${K8S_DIR}/agent-manager-deployment.yaml" +kubectl apply -f "${K8S_DIR}/agent-manager-service.yaml" + +echo "Waiting for rollout..." +kubectl rollout status deployment/agent-manager -n agent-manager --timeout=300s + +echo "" +echo "Pods:" +kubectl get pods -n agent-manager -o wide +echo "" +echo "Service:" +kubectl get svc agent-manager -n agent-manager -o wide + +echo "" +echo "Done." + diff --git a/scripts/setup_dns_permissions.sh b/scripts/setup_dns_permissions.sh new file mode 100755 index 0000000..6ab7053 --- /dev/null +++ b/scripts/setup_dns_permissions.sh @@ -0,0 +1,192 @@ +#!/bin/bash +# ============================================================================= +# Agent Manager DNS权限配置脚本 +# ============================================================================= +# +# 用途:为Azure Service Principal分配DNS Zone管理权限 +# +# 前提条件: +# 1. 已安装Azure CLI (az命令) +# 2. 已登录到正确的Azure账户 (az login) +# 3. 已选择正确的subscription (az account set) +# 4. 有足够权限分配角色(Owner或User Access Administrator) +# +# 使用方法: +# bash scripts/setup_dns_permissions.sh +# +# ============================================================================= + +set -e # 遇到错误立即退出 + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# ============================================================================= +# 配置部分 - 根据实际环境修改 +# ============================================================================= + +# 从k8s secret读取配置(如果可用) +if command -v kubectl &> /dev/null && kubectl get secret agent-manager-secret -n agent-manager &> /dev/null 2>&1; then + log_info "从Kubernetes Secret读取配置..." + + AZURE_CLIENT_ID=$(kubectl get secret agent-manager-secret -n agent-manager -o jsonpath='{.data.AZURE_CLIENT_ID}' 2>/dev/null | base64 -d) + AZURE_SUBSCRIPTION_ID=$(kubectl get secret agent-manager-secret -n agent-manager -o jsonpath='{.data.AZURE_SUBSCRIPTION_ID}' 2>/dev/null | base64 -d) + AZURE_RESOURCE_GROUP=$(kubectl get secret agent-manager-secret -n agent-manager -o jsonpath='{.data.AZURE_RESOURCE_GROUP}' 2>/dev/null | base64 -d) + AZURE_DNS_ZONE=$(kubectl get secret agent-manager-secret -n agent-manager -o jsonpath='{.data.AZURE_DNS_ZONE}' 2>/dev/null | base64 -d) +else + log_warning "无法从Kubernetes读取配置,使用默认值" + + # 默认配置 + AZURE_CLIENT_ID="${AZURE_CLIENT_ID:-c5ba26db-f180-425f-bac3-93708d853988}" + AZURE_SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID:-45d7a360-af09-40fc-9afc-56dc475245ec}" + AZURE_RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:-taiji-ai-v0}" + AZURE_DNS_ZONE="${AZURE_DNS_ZONE:-taijiagnet.com}" +fi + +# ============================================================================= +# 主逻辑 +# ============================================================================= + +echo "" +echo "=========================================" +echo " Agent Manager DNS权限配置" +echo "=========================================" +echo "" + +log_info "配置信息:" +echo " Service Principal ID: $AZURE_CLIENT_ID" +echo " Subscription ID: $AZURE_SUBSCRIPTION_ID" +echo " Resource Group: $AZURE_RESOURCE_GROUP" +echo " DNS Zone: $AZURE_DNS_ZONE" +echo "" + +# 1. 检查Azure CLI +log_info "检查Azure CLI..." +if ! command -v az &> /dev/null; then + log_error "Azure CLI未安装,请先安装: https://docs.microsoft.com/cli/azure/install-azure-cli" + exit 1 +fi +log_success "Azure CLI已安装" + +# 2. 检查Azure登录状态 +log_info "检查Azure登录状态..." +if ! az account show &> /dev/null; then + log_error "未登录Azure,请先运行: az login" + exit 1 +fi +log_success "已登录Azure" + +# 3. 切换到正确的subscription +log_info "切换到目标subscription..." +CURRENT_SUB=$(az account show --query id -o tsv) +if [ "$CURRENT_SUB" != "$AZURE_SUBSCRIPTION_ID" ]; then + log_warning "当前subscription ($CURRENT_SUB) 与目标不同,正在切换..." + az account set --subscription "$AZURE_SUBSCRIPTION_ID" + log_success "已切换到subscription: $AZURE_SUBSCRIPTION_ID" +else + log_success "已在正确的subscription" +fi + +# 4. 验证DNS Zone存在 +log_info "验证DNS Zone是否存在..." +if ! az network dns zone show \ + --name "$AZURE_DNS_ZONE" \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --output none 2>/dev/null; then + log_error "DNS Zone '$AZURE_DNS_ZONE' 不存在于资源组 '$AZURE_RESOURCE_GROUP'" + exit 1 +fi +log_success "DNS Zone存在" + +# 构建DNS Zone资源ID +DNS_ZONE_ID="/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.Network/dnsZones/$AZURE_DNS_ZONE" + +# 5. 检查当前权限 +log_info "检查Service Principal当前权限..." +EXISTING_ROLES=$(az role assignment list \ + --assignee "$AZURE_CLIENT_ID" \ + --scope "$DNS_ZONE_ID" \ + --query "[].roleDefinitionName" \ + --output tsv) + +if echo "$EXISTING_ROLES" | grep -q "DNS Zone Contributor"; then + log_success "Service Principal已拥有DNS Zone Contributor角色" + echo "" + echo "当前角色分配:" + az role assignment list \ + --assignee "$AZURE_CLIENT_ID" \ + --scope "$DNS_ZONE_ID" \ + --output table + echo "" + log_info "无需重复分配权限" + exit 0 +fi + +# 6. 分配DNS Zone Contributor角色 +log_info "分配DNS Zone Contributor角色..." +if az role assignment create \ + --assignee "$AZURE_CLIENT_ID" \ + --role "DNS Zone Contributor" \ + --scope "$DNS_ZONE_ID" \ + --output none; then + log_success "DNS Zone Contributor角色分配成功" +else + log_error "角色分配失败,请检查是否有足够的权限" + exit 1 +fi + +# 7. 验证权限分配 +log_info "验证权限分配..." +sleep 3 # 等待权限生效 + +echo "" +echo "当前角色分配:" +az role assignment list \ + --assignee "$AZURE_CLIENT_ID" \ + --scope "$DNS_ZONE_ID" \ + --output table + +echo "" +log_success "✅ DNS权限配置完成!" +echo "" +log_info "Service Principal现在可以管理DNS Zone '$AZURE_DNS_ZONE' 的DNS记录" +log_info "Agent Manager可以为创建的agent自动配置DNS域名" +echo "" + +# 8. 测试DNS权限(可选) +read -p "是否要测试DNS记录创建权限?(y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + TEST_RECORD="test-dns-permission-check" + TEST_IP="1.2.3.4" + + log_info "创建测试DNS记录: $TEST_RECORD.$AZURE_DNS_ZONE -> $TEST_IP" + + # 使用Azure REST API测试(模拟agent-manager的行为) + log_warning "注意:此测试需要AZURE_CLIENT_SECRET,将跳过" + log_info "请在agent-manager中创建一个测试agent来验证DNS功能" +fi + +echo "" +log_success "配置完成!" diff --git a/tool_storage.py b/tool_storage.py new file mode 100644 index 0000000..c0d8516 --- /dev/null +++ b/tool_storage.py @@ -0,0 +1,314 @@ +""" +工具存储管理器 +负责存储和管理外部数据工具的配置和代码 +符合 MCP-Server 调用规范 +""" + +import os +import json +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class ToolStorage: + """工具存储管理器 - 管理外部数据工具的配置和代码文件""" + + def __init__(self, storage_path: str = None): + """ + 初始化工具存储 + + Args: + storage_path: 工具存储路径,默认使用 ./tool_storage + """ + self.storage_path = Path(storage_path or os.getenv("TOOL_STORAGE_PATH", "./tool_storage")) + self.storage_path.mkdir(parents=True, exist_ok=True) + + # 内存缓存 + self._cache: Dict[str, Dict] = {} + + # 加载已有工具 + self._load_existing_tools() + + def _load_existing_tools(self): + """从文件系统加载已有的工具配置""" + try: + for tool_dir in self.storage_path.iterdir(): + if tool_dir.is_dir(): + config_file = tool_dir / "config.json" + if config_file.exists(): + with open(config_file, "r", encoding="utf-8") as f: + config = json.load(f) + self._cache[config["tool_ref_id"]] = config + + logger.info(f"✅ 已加载 {len(self._cache)} 个工具配置") + except Exception as e: + logger.error(f"加载工具配置失败: {e}") + + def _get_tool_dir(self, tool_ref_id: str) -> Path: + """获取工具的存储目录""" + return self.storage_path / tool_ref_id + + def save_tool( + self, + tool_ref_id: str, + name: str, + description: str, + config: Dict, + code: str, + user_id: str, + tenant_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + 保存工具配置和代码 + + Args: + tool_ref_id: 工具唯一标识 + name: 工具名称 + description: 工具描述 + config: 工具配置(含认证、参数等) + code: 生成的 Python 代码 + user_id: 用户 ID + tenant_id: 租户 ID + + Returns: + 保存结果 + """ + tool_dir = self._get_tool_dir(tool_ref_id) + tool_dir.mkdir(parents=True, exist_ok=True) + + # 保存配置 + tool_data = { + "tool_ref_id": tool_ref_id, + "name": name, + "description": description, + "config": config, + "user_id": user_id, + "tenant_id": tenant_id, + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat(), + "status": "created" + } + + config_file = tool_dir / "config.json" + with open(config_file, "w", encoding="utf-8") as f: + json.dump(tool_data, f, ensure_ascii=False, indent=2) + + # 保存代码文件 + code_file = tool_dir / f"{self._to_python_name(name)}.py" + with open(code_file, "w", encoding="utf-8") as f: + f.write(code) + + # 更新缓存 + tool_data["code_file"] = str(code_file) + self._cache[tool_ref_id] = tool_data + + logger.info(f"✅ 工具已保存: {tool_ref_id}") + return {"success": True, "tool_ref_id": tool_ref_id, "code_file": str(code_file)} + + def get_tool(self, tool_ref_id: str) -> Optional[Dict]: + """ + 获取工具配置 + + Args: + tool_ref_id: 工具唯一标识 + + Returns: + 工具配置,不存在返回 None + """ + # 先从缓存获取 + if tool_ref_id in self._cache: + return self._cache[tool_ref_id] + + # 从文件系统加载 + tool_dir = self._get_tool_dir(tool_ref_id) + config_file = tool_dir / "config.json" + + if config_file.exists(): + with open(config_file, "r", encoding="utf-8") as f: + tool_data = json.load(f) + self._cache[tool_ref_id] = tool_data + return tool_data + + return None + + def get_tool_code(self, tool_ref_id: str) -> Optional[str]: + """ + 获取工具代码 + + Args: + tool_ref_id: 工具唯一标识 + + Returns: + 工具代码,不存在返回 None + """ + tool = self.get_tool(tool_ref_id) + if not tool: + return None + + name = tool.get("name", "tool") + tool_dir = self._get_tool_dir(tool_ref_id) + code_file = tool_dir / f"{self._to_python_name(name)}.py" + + if code_file.exists(): + with open(code_file, "r", encoding="utf-8") as f: + return f.read() + + return None + + def update_tool(self, tool_ref_id: str, updates: Dict) -> Optional[Dict]: + """ + 更新工具配置 + + Args: + tool_ref_id: 工具唯一标识 + updates: 更新内容 + + Returns: + 更新后的配置 + """ + tool = self.get_tool(tool_ref_id) + if not tool: + return None + + # 更新配置 + if "description" in updates: + tool["description"] = updates["description"] + tool["config"]["description"] = updates["description"] + + for key in ["url", "method", "headers", "auth", "request_params", "request_body", "timeout"]: + if key in updates and updates[key] is not None: + tool["config"][key] = updates[key] + + tool["updated_at"] = datetime.utcnow().isoformat() + + # 保存到文件 + tool_dir = self._get_tool_dir(tool_ref_id) + config_file = tool_dir / "config.json" + + save_data = {k: v for k, v in tool.items() if k != "code_file"} + with open(config_file, "w", encoding="utf-8") as f: + json.dump(save_data, f, ensure_ascii=False, indent=2) + + # 更新缓存 + self._cache[tool_ref_id] = tool + + return tool + + def delete_tool(self, tool_ref_id: str) -> bool: + """ + 删除工具 + + Args: + tool_ref_id: 工具唯一标识 + + Returns: + 是否删除成功 + """ + tool_dir = self._get_tool_dir(tool_ref_id) + + if tool_dir.exists(): + import shutil + shutil.rmtree(tool_dir) + + if tool_ref_id in self._cache: + del self._cache[tool_ref_id] + + logger.info(f"✅ 工具已删除: {tool_ref_id}") + return True + + def list_tools(self, user_id: Optional[str] = None, tenant_id: Optional[str] = None) -> List[Dict]: + """ + 列出工具 + + Args: + user_id: 用户 ID 过滤 + tenant_id: 租户 ID 过滤 + + Returns: + 工具列表 + """ + tools = list(self._cache.values()) + + if user_id: + tools = [t for t in tools if t.get("user_id") == user_id] + + if tenant_id: + tools = [t for t in tools if t.get("tenant_id") == tenant_id] + + return tools + + def get_tools_by_refs(self, tool_refs: List[str]) -> List[Dict]: + """ + 根据 tool_ref_id 列表获取工具 + + Args: + tool_refs: tool_ref_id 列表 + + Returns: + 工具列表(仅返回存在的工具) + """ + tools = [] + for ref in tool_refs: + tool = self.get_tool(ref) + if tool: + tools.append(tool) + return tools + + def check_tool_in_use(self, tool_ref_id: str) -> bool: + """ + 检查工具是否被 Agent 使用 + + Args: + tool_ref_id: 工具唯一标识 + + Returns: + 是否被使用 + """ + tool = self.get_tool(tool_ref_id) + if not tool: + return False + + return tool.get("status") == "in_use" or bool(tool.get("used_by_agents")) + + def mark_tool_in_use(self, tool_ref_id: str, agent_name: str) -> bool: + """ + 标记工具被 Agent 使用 + + Args: + tool_ref_id: 工具唯一标识 + agent_name: Agent 名称 + + Returns: + 是否成功 + """ + tool = self.get_tool(tool_ref_id) + if not tool: + return False + + if "used_by_agents" not in tool: + tool["used_by_agents"] = [] + + if agent_name not in tool["used_by_agents"]: + tool["used_by_agents"].append(agent_name) + + tool["status"] = "in_use" + + # 保存更新 + self.update_tool(tool_ref_id, {"status": "in_use"}) + return True + + def _to_python_name(self, name: str) -> str: + """将名称转换为 Python 函数名格式""" + import re + name = re.sub(r'[^a-zA-Z0-9_]', '_', name) + if name and name[0].isdigit(): + name = '_' + name + return name.lower() + + +# 全局实例 +tool_storage = ToolStorage()