Files
taiji-AI-PAD/services/mcp-server/app/agent_manager_client.py
T
2026-03-10 06:40:38 +00:00

1703 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AI Agent Manager API Client
Encapsulates HTTP calls to the AI Agent Manager service
Adapts to the actual Agent Manager interface (based on agent-manager-interface-doc.md)
"""
import os
import httpx
import structlog
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field
from enum import Enum
logger = structlog.get_logger(__name__)
# Get Agent Manager URL from environment variables
AGENT_MANAGER_URL = os.getenv("AGENT_MANAGER_URL", "http://localhost:8000")
# LLM_BASE_URL - 所有 Agent(平台和自定义)都必须传递的固定参数
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io")
class AgentStatus(str, Enum):
"""Agent Pod Status"""
PENDING = "Pending"
RUNNING = "Running"
SUCCEEDED = "Succeeded"
FAILED = "Failed"
UNKNOWN = "Unknown"
@dataclass
class AgentConfig:
"""
Agent Resource Configuration
Adapts to the config parameter of the Agent Manager POST /agents interface
"""
user_id: Optional[str] = None # User identifier for multi-tenancy management
cpu_request: Optional[str] = "100m"
cpu_limit: Optional[str] = "500m"
memory_request: Optional[str] = "128Mi"
memory_limit: Optional[str] = "512Mi"
replicas: Optional[int] = 1 # Number of replicas (instances)
def to_dict(self) -> Dict[str, Any]:
"""Converts to API request format"""
result = {}
if self.user_id:
result["user_id"] = self.user_id
if self.cpu_request:
result["cpu_request"] = self.cpu_request
if self.cpu_limit:
result["cpu_limit"] = self.cpu_limit
if self.memory_request:
result["memory_request"] = self.memory_request
if self.memory_limit:
result["memory_limit"] = self.memory_limit
if self.replicas is not None:
result["replicas"] = self.replicas
return result
@dataclass
class ContainerStatus:
"""
Container Status
Adapts to the containers field in the Agent Manager GET /agents/{name}/status interface
"""
name: str
ready: bool = False
restart_count: int = 0
state: str = "unknown" # running/waiting/terminated
reason: Optional[str] = None
message: Optional[str] = None
exit_code: Optional[int] = None
started_at: Optional[str] = None
finished_at: Optional[str] = None
@dataclass
class TemplateInfo:
"""
Template Information
Adapts to the Agent Manager GET /templates interface response
Agent Manager now returns:
- template: technical name (e.g., echo_agent)
- displayName: human-readable name (e.g., "Echo 测试服务")
- description: template description
- category: template category (e.g., testing, assistant)
- port: service port
- env_info: environment variable configuration
"""
template: str
display_name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
port: Optional[int] = None
env_info: Dict[str, Any] = field(default_factory=dict)
template_type: Optional[str] = None # platform or custom
@dataclass
class AgentCreateResult:
"""
Agent Creation Result
Adapts to the Agent Manager POST /agents interface response
"""
name: str
namespace: str
status: str
created_at: str
template: str
service_port: Optional[int] = None
access_info: Optional[Dict[str, Any]] = None
# New fields (adapted to actual interface response)
pod_id: Optional[str] = None
pod_ip: Optional[str] = None
host_ip: Optional[str] = None
node_name: Optional[str] = None
owner_info: Optional[Dict[str, Any]] = None
@dataclass
class AgentStatusResult:
"""
Agent Status Result
Adapts to the Agent Manager GET /agents/{name}/status interface response
Actual Agent Manager return format (after v2 update):
{
"name": "alice-echo",
"namespace": "ai-agents",
"status": "Running",
"health_status": "healthy",
"created_at": "2026-01-06T09:00:00Z",
"pod_ip": "10.244.1.100",
"host_ip": "192.168.1.10",
"node_name": "node-1",
"labels": {"template": "echo_agent", "user_id": "alice"},
"service_port": 8080,
"access_url": "http://alice-echo.ai-agents.svc.cluster.local:8080",
"containers": [
{
"name": "agent",
"ready": true,
"restart_count": 0,
"state": "running",
"started_at": "2026-01-06T09:00:05Z"
}
],
"resources": {
"requests": {"cpu": "100m", "memory": "128Mi"},
"limits": {"cpu": "500m", "memory": "512Mi"}
},
"endpoints": ["http://10.244.1.100:8080"],
"conditions": [
{"type": "Ready", "status": "True"},
{"type": "ContainersReady", "status": "True"}
]
}
Health status description:
- healthy: Pod is running normally, all containers are ready
- unhealthy: Pod is running abnormally, some containers are not ready or have crashed
- degraded: Pod is running but with warnings (e.g., too many restarts)
"""
name: str
namespace: str
status: str
# New: Health status (healthy/unhealthy/degraded)
health_status: str = "unknown"
created_at: Optional[str] = None
pod_ip: Optional[str] = None
host_ip: Optional[str] = None
node_name: Optional[str] = None
labels: Optional[Dict[str, str]] = None
# New: Service port and access URL
service_port: Optional[int] = None
access_url: Optional[str] = None
# New: Container status list
containers: List[ContainerStatus] = field(default_factory=list)
# New: Resource configuration
resources: Optional[Dict[str, Any]] = None
# New: Endpoint list
endpoints: Optional[List[str]] = None
# New: Pod conditions
conditions: Optional[List[Dict[str, Any]]] = None
# Compatible with old fields
template: Optional[str] = None
# ========== 访问信息字段(从 access_info 解析) ==========
external_ip: Optional[str] = None # 外网 IP 地址
domain: Optional[str] = None # 域名
domain_url: Optional[str] = None # 域名访问地址
ip_url: Optional[str] = None # IP 访问地址
# ======================================================
@property
def is_healthy(self) -> bool:
"""Is it healthy"""
return self.health_status == "healthy"
@property
def is_running(self) -> bool:
"""Is it running"""
return self.status.lower() == "running"
@property
def is_ready(self) -> bool:
"""Is it ready (all containers are ready)"""
if not self.containers:
return self.is_running
return all(c.ready for c in self.containers)
@property
def total_restart_count(self) -> int:
"""Total restart count of all containers"""
return sum(c.restart_count for c in self.containers)
@property
def has_crashed_container(self) -> bool:
"""Is there a crashed container"""
for c in self.containers:
if c.state == "terminated" and c.exit_code != 0:
return True
if c.reason in ("CrashLoopBackOff", "Error", "OOMKilled"):
return True
return False
@property
def cpu_request(self) -> Optional[str]:
"""Get CPU request"""
if self.resources and "requests" in self.resources:
return self.resources["requests"].get("cpu")
return None
@property
def cpu_limit(self) -> Optional[str]:
"""Get CPU limit"""
if self.resources and "limits" in self.resources:
return self.resources["limits"].get("cpu")
return None
@property
def memory_request(self) -> Optional[str]:
"""Get memory request"""
if self.resources and "requests" in self.resources:
return self.resources["requests"].get("memory")
return None
@property
def memory_limit(self) -> Optional[str]:
"""Get memory limit"""
if self.resources and "limits" in self.resources:
return self.resources["limits"].get("memory")
return None
@dataclass
class AgentMetricsResult:
"""
Agent Resource Usage Result
Adapts to the Agent Manager GET /agents/{name}/metrics interface response
Actual Agent Manager return format:
{
"name": "alice-echo",
"namespace": "ai-agents",
"requests": {
"cpu": "100m",
"memory": "128Mi"
},
"limits": {
"cpu": "500m",
"memory": "512Mi"
},
"usage": {
"cpu": "14502n",
"memory": "8704Ki"
},
"timestamp": "2026-01-06T05:01:04Z",
"metrics_available": null
}
Field description:
- requests/limits: Resource quotas (obtained from Pod spec)
- usage: Real-time resource usage (obtained from metrics-server, requires metrics-server to be installed in the cluster)
- timestamp: Timestamp of the metrics data (ISO 8601 format)
- metrics_available: Whether the metrics-server is available
CPU unit description:
- n (nanocores): 1 core = 1,000,000,000n
- m (millicores): 1 core = 1,000m
- e.g.: 14502n = 0.014502m ≈ 0.000014 cores
Memory unit description:
- Ki (Kibibytes): 1024 bytes
- Mi (Mebibytes): 1024 KiB
- e.g.: 8704Ki ≈ 8.5 MiB
"""
name: str
namespace: str = "ai-agents"
requests: Dict[str, str] = field(default_factory=dict)
limits: Dict[str, str] = field(default_factory=dict)
# New: Real-time resource usage (from metrics-server)
usage: Optional[Dict[str, str]] = None
# New: metrics data timestamp
timestamp: Optional[str] = None
# New: whether metrics-server is available
metrics_available: Optional[bool] = None
# Compatible with old format
resources: Dict[str, Any] = field(default_factory=dict)
@staticmethod
def _parse_cpu_to_millicores(cpu_str: str) -> float:
"""
Converts CPU string to millicores
Supported units:
- n (nanocores): 1m = 1,000,000n
- m (millicores): direct use
- no unit: treated as cores, multiplied by 1000
Args:
cpu_str: CPU string, e.g. "100m", "14502n", "0.5"
Returns:
Number of millicores (float)
"""
if not cpu_str or cpu_str == "0":
return 0.0
cpu_str = str(cpu_str).strip()
try:
if cpu_str.endswith("n"):
# nanocores -> millicores: 1m = 1,000,000n
return float(cpu_str[:-1]) / 1_000_000
elif cpu_str.endswith("m"):
# millicores
return float(cpu_str[:-1])
else:
# cores -> millicores
return float(cpu_str) * 1000
except ValueError:
return 0.0
@staticmethod
def _parse_memory_to_mb(mem_str: str) -> float:
"""
Converts memory string to MB
Supported units:
- Ki (Kibibytes): 1 MiB = 1024 KiB
- Mi (Mebibytes): direct use
- Gi (Gibibytes): 1 GiB = 1024 MiB
- K (Kilobytes): 1 MB = 1000 KB
- M (Megabytes): direct use
- G (Gigabytes): 1 GB = 1000 MB
- no unit: treated as bytes
Args:
mem_str: Memory string, e.g. "128Mi", "8704Ki", "1Gi"
Returns:
Number of MB (float)
"""
if not mem_str or mem_str == "0":
return 0.0
mem_str = str(mem_str).strip()
try:
# Binary units (IEC)
if mem_str.endswith("Ki"):
return float(mem_str[:-2]) / 1024
elif mem_str.endswith("Mi"):
return float(mem_str[:-2])
elif mem_str.endswith("Gi"):
return float(mem_str[:-2]) * 1024
elif mem_str.endswith("Ti"):
return float(mem_str[:-2]) * 1024 * 1024
# Decimal units (SI)
elif mem_str.endswith("K"):
return float(mem_str[:-1]) / 1000
elif mem_str.endswith("M"):
return float(mem_str[:-1])
elif mem_str.endswith("G"):
return float(mem_str[:-1]) * 1000
elif mem_str.endswith("T"):
return float(mem_str[:-1]) * 1000 * 1000
else:
# bytes -> MB
return float(mem_str) / (1024 * 1024)
except ValueError:
return 0.0
@property
def cpu_request(self) -> str:
"""Get CPU request (e.g. '100m')"""
return self.requests.get("cpu", "0")
@property
def memory_request(self) -> str:
"""Get memory request (e.g. '128Mi')"""
return self.requests.get("memory", "0")
@property
def cpu_limit(self) -> str:
"""Get CPU limit (e.g. '500m')"""
return self.limits.get("cpu", "0")
@property
def memory_limit(self) -> str:
"""Get memory limit (e.g. '512Mi')"""
return self.limits.get("memory", "0")
@property
def cpu_usage_current(self) -> str:
"""Get current CPU usage (e.g. '14502n')"""
if self.usage:
return self.usage.get("cpu", "0")
return "0"
@property
def memory_usage_current(self) -> str:
"""Get current memory usage (e.g. '8704Ki')"""
if self.usage:
return self.usage.get("memory", "0")
return "0"
# Compatible with old cpu_usage/memory_usage attribute names (use limits as display value)
@property
def cpu_usage(self) -> str:
"""Get CPU limit (compatible with old attribute name)"""
return self.cpu_limit
@property
def memory_usage(self) -> str:
"""Get memory limit (compatible with old attribute name)"""
return self.memory_limit
@property
def available(self) -> bool:
"""Is it available (based on whether there are limits configured)"""
return bool(self.limits.get("cpu") or self.limits.get("memory"))
@property
def has_realtime_metrics(self) -> bool:
"""Whether there is real-time metrics data"""
return self.usage is not None and bool(self.usage)
@property
def cpu_limit_millicores(self) -> float:
"""Get CPU limit (millicores, e.g. 500m -> 500.0)"""
return self._parse_cpu_to_millicores(self.cpu_limit)
@property
def cpu_request_millicores(self) -> float:
"""Get CPU request (millicores)"""
return self._parse_cpu_to_millicores(self.cpu_request)
@property
def cpu_usage_current_millicores(self) -> float:
"""Get current CPU usage (millicores)"""
return self._parse_cpu_to_millicores(self.cpu_usage_current)
@property
def memory_limit_mb(self) -> float:
"""Get memory limit (MB)"""
return self._parse_memory_to_mb(self.memory_limit)
@property
def memory_request_mb(self) -> float:
"""Get memory request (MB)"""
return self._parse_memory_to_mb(self.memory_request)
@property
def memory_usage_current_mb(self) -> float:
"""Get current memory usage (MB)"""
return self._parse_memory_to_mb(self.memory_usage_current)
@property
def cpu_utilization_percent(self) -> Optional[float]:
"""
Get CPU utilization (percentage relative to limit)
Returns:
Utilization percentage, or None if there is no real-time data
"""
if not self.has_realtime_metrics:
return None
limit = self.cpu_limit_millicores
if limit <= 0:
return None
usage = self.cpu_usage_current_millicores
return (usage / limit) * 100
@property
def memory_utilization_percent(self) -> Optional[float]:
"""
Get memory utilization (percentage relative to limit)
Returns:
Utilization percentage, or None if there is no real-time data
"""
if not self.has_realtime_metrics:
return None
limit = self.memory_limit_mb
if limit <= 0:
return None
usage = self.memory_usage_current_mb
return (usage / limit) * 100
# Compatible with old attribute names
@property
def cpu_usage_millicores(self) -> float:
"""Get CPU limit (millicores, compatible with old attribute name)"""
return self.cpu_limit_millicores
@property
def memory_usage_mb(self) -> float:
"""Get memory limit (MB, compatible with old attribute name)"""
return self.memory_limit_mb
@dataclass
class AgentListResult:
"""
Agent List Result
Adapts to the Agent Manager GET /agents interface response
"""
agents: List[Dict[str, Any]]
count: int
class AgentManagerError(Exception):
"""Agent Manager API Error"""
def __init__(self, message: str, status_code: int = 500, detail: Any = None):
self.message = message
self.status_code = status_code
self.detail = detail
super().__init__(message)
class AgentManagerClient:
"""
AI Agent Manager API Client
Adapts to the actual Agent Manager interface:
- POST /agents - Create Agent
- GET /agents - Get all Agents
- GET /agents/{name}/status - Get Agent status
- GET /agents/{name}/metrics - Get resource usage
- DELETE /agents/{name} - Delete Agent
- GET /templates - Get all templates
- GET /templates/platform - Get platform templates
- GET /templates/custom - Get custom templates
- GET /templates/{name} - Get template details
- GET / - Health check
"""
def __init__(self, base_url: Optional[str] = None, timeout: float = 30.0):
"""
Initializes the client
Args:
base_url: API base URL, defaults to environment variable
timeout: Request timeout in seconds
"""
self.base_url = (base_url or AGENT_MANAGER_URL).rstrip("/")
self.timeout = timeout
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
headers={"Content-Type": "application/json"}
)
return self._client
async def close(self):
"""Close the client"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def _request(
self,
method: str,
path: str,
json: Optional[Dict] = None,
params: Optional[Dict] = None
) -> Dict[str, Any]:
"""
Send HTTP request
Args:
method: HTTP method
path: API path
json: Request body
params: Query parameters
Returns:
Response JSON
Raises:
AgentManagerError: API call failed
"""
client = await self._get_client()
try:
response = await client.request(
method=method,
url=path,
json=json,
params=params
)
if response.status_code >= 400:
try:
detail = response.json()
except Exception:
detail = response.text
logger.error(
"agent_manager_api_error",
method=method,
path=path,
status_code=response.status_code,
detail=detail
)
raise AgentManagerError(
message=f"API call failed: {response.status_code}",
status_code=response.status_code,
detail=detail
)
return response.json()
except httpx.RequestError as e:
logger.error(
"agent_manager_connection_error",
method=method,
path=path,
error=str(e)
)
raise AgentManagerError(
message=f"Failed to connect to Agent Manager: {str(e)}",
status_code=503,
detail={"error": "connection_error", "message": str(e)}
)
# ==================== 健康检查 ====================
async def health_check(self) -> Dict[str, Any]:
"""
Check Agent Manager service status
Call: GET /
Returns:
Service status information, including:
- service: service name
- version: version
- status: status
"""
return await self._request("GET", "/")
# ==================== Template Management ====================
async def list_templates(self) -> List[TemplateInfo]:
"""
Get all available templates
Call: GET /templates
Returns:
Template list
"""
data = await self._request("GET", "/templates")
templates = []
for t in data.get("templates", []):
templates.append(TemplateInfo(
template=t["template"],
display_name=t.get("displayName"),
description=t.get("description"),
category=t.get("category"),
port=t.get("port"),
env_info=t.get("env_info", {})
))
return templates
async def list_platform_templates(self) -> List[TemplateInfo]:
"""
Get all platform Agent templates
Call: GET /templates/platform
Platform Agent templates are predefined Agent types managed by the platform.
Returns:
Platform template list
"""
data = await self._request("GET", "/templates/platform")
templates = []
for t in data.get("templates", []):
templates.append(TemplateInfo(
template=t["template"],
display_name=t.get("display_name"),
description=t.get("description"),
category=t.get("category"),
port=t.get("port"),
env_info=t.get("env_info", {}),
template_type="platform"
))
return templates
async def list_custom_templates(self) -> List[TemplateInfo]:
"""
Get all custom Agent templates
Call: GET /templates/custom
Custom Agent templates require users to configure environment variables (e.g., API Key).
Returns:
Custom template list, each template's env_info includes:
- required: required environment variables
- optional: optional environment variables
"""
data = await self._request("GET", "/templates/custom")
templates = []
for t in data.get("templates", []):
templates.append(TemplateInfo(
template=t["template"],
display_name=t.get("display_name"),
description=t.get("description"),
category=t.get("category"),
port=t.get("port"),
env_info=t.get("env_info", {}),
template_type="custom"
))
return templates
async def get_template(self, template_name: str) -> TemplateInfo:
"""
Get template details
Call: GET /templates/{template_name}
Args:
template_name: Template name
Returns:
Template information
"""
data = await self._request("GET", f"/templates/{template_name}")
return TemplateInfo(
template=data["template"],
port=data.get("port"),
env_info=data.get("env_info", {})
)
# ==================== Agent Management ====================
async def create_agent(
self,
name: str,
template: str,
config: Optional[AgentConfig] = None,
env: Optional[Dict[str, str]] = None
) -> AgentCreateResult:
"""
Create Agent Pod
Call: POST /agents
This is the unified Agent creation interface, applicable to both platform and custom Agents.
Args:
name: Agent name (1-63 characters, lowercase letters, numbers, hyphens)
template: Template type (e.g., echo_agent, mysql_agent)
config: Resource configuration (including user_id, cpu, memory, etc.)
env: Environment variables (required for custom Agents, e.g., API Key)
Returns:
Creation result
Example:
# Create a platform Agent (single replica)
result = await client.create_agent(
name="alice-echo",
template="echo_agent",
config=AgentConfig(user_id="alice", replicas=1)
)
# Create a custom Agent (multiple replicas)
result = await client.create_agent(
name="my-mysql-agent",
template="mysql_agent",
config=AgentConfig(user_id="alice", replicas=2),
env={
"MYSQL_HOST": "mysql.example.com",
"MYSQL_USER": "root",
"MYSQL_PASSWORD": "password",
"MYSQL_DATABASE": "mydb",
"OPENAI_API_KEY": "sk-..."
}
)
"""
payload: Dict[str, Any] = {
"name": name,
"template": template
}
if config:
payload["config"] = config.to_dict()
# 构建环境变量,确保 LLM_BASE_URL 始终被传递(平台 Agent 和自定义 Agent 都需要)
final_env = {"LLM_BASE_URL": LLM_BASE_URL}
if env:
# 用户传入的环境变量会覆盖默认值(但通常不应覆盖 LLM_BASE_URL)
final_env.update(env)
payload["env"] = final_env
logger.info(
"creating_agent",
name=name,
template=template,
config=config.to_dict() if config else None,
has_env=True,
llm_base_url=LLM_BASE_URL
)
data = await self._request("POST", "/agents", json=payload)
return AgentCreateResult(
name=data["name"],
namespace=data["namespace"],
status=data["status"],
created_at=data["created_at"],
template=data["template"],
service_port=data.get("service_port"),
access_info=data.get("access_info"),
pod_id=data.get("pod_id"),
pod_ip=data.get("pod_ip"),
host_ip=data.get("host_ip"),
node_name=data.get("node_name"),
owner_info=data.get("owner_info")
)
async def create_platform_agent(
self,
name: str,
template: str,
user_id: str,
config: Optional[AgentConfig] = None
) -> AgentCreateResult:
"""
Create a platform Agent instance
This is a convenience method for create_agent, used to create platform Agents.
Platform Agents use predefined images from the platform, so users do not need to configure environment variables.
Args:
name: Agent instance name
template: Platform template name (e.g., echo_agent, jina_search_agent)
user_id: User ID (for resource isolation and billing)
config: Resource configuration (optional, uses template defaults)
Returns:
Creation result
"""
if config is None:
config = AgentConfig(user_id=user_id)
else:
config.user_id = user_id
logger.info(
"creating_platform_agent",
name=name,
template=template,
user_id=user_id
)
return await self.create_agent(
name=name,
template=template,
config=config
)
async def create_custom_agent(
self,
name: str,
template: str,
user_id: str,
env_vars: Dict[str, str],
config: Optional[AgentConfig] = None
) -> AgentCreateResult:
"""
Create a custom Agent instance
This is a convenience method for create_agent, used to create custom Agents.
Custom Agents require the user to provide environment variables (e.g., API keys, database connection info).
Args:
name: Agent instance name
template: Custom template name (e.g., mysql_agent, postgresql_agent)
user_id: User ID
env_vars: Environment variables (required, contains sensitive info like API keys)
config: Resource configuration (optional)
Returns:
Creation result
Example:
result = await client.create_custom_agent(
name="my-mysql-agent",
template="mysql_agent",
user_id="alice",
env_vars={
"MYSQL_HOST": "mysql.example.com",
"MYSQL_USER": "root",
"MYSQL_PASSWORD": "password",
"MYSQL_DATABASE": "mydb",
"OPENAI_API_KEY": "sk-..."
}
)
"""
if config is None:
config = AgentConfig(user_id=user_id)
else:
config.user_id = user_id
logger.info(
"creating_custom_agent",
name=name,
template=template,
user_id=user_id,
env_keys=list(env_vars.keys()) if env_vars else []
)
return await self.create_agent(
name=name,
template=template,
config=config,
env=env_vars
)
async def generate_agent_from_tools(
self,
agent_name: str,
description: str,
tools: List[Dict[str, Any]],
user_id: str,
tenant_id: Optional[str] = None,
auto_deploy: bool = True
) -> Dict[str, Any]:
"""
使用外部数据工具生成 Agent
调用 Agent Manager 的 POST /external-tools/agents/create-with-tools 接口
不需要预定义模板,根据工具配置动态生成 Agent
Args:
agent_name: Agent 名称
description: Agent 描述
tools: 工具配置列表,每个工具包含:
- name: 工具名称
- description: 工具描述
- url: API 端点 URL
- method: HTTP 方法
- user_id: 用户 ID
- headers: (可选) 自定义请求头
- auth: (可选) 认证配置
- request_params: (可选) URL 查询参数定义
- request_body: (可选) 请求体定义
- response_mapping: (可选) 响应字段映射
- timeout: (可选) 超时时间
- retry: (可选) 重试配置
user_id: 用户 ID
tenant_id: (可选) 租户 ID
auto_deploy: 是否自动部署,默认 True
Returns:
生成结果,包含 agent_id、status 等信息
Example:
result = await client.generate_agent_from_tools(
agent_name="my-api-agent",
description="调用外部 API 的 Agent",
tools=[
{
"name": "天气查询",
"description": "查询城市天气",
"url": "https://api.weather.com/forecast",
"method": "GET",
"user_id": "user-001",
"auth": {"type": "api_key", "key": "sk-xxx", "in": "header", "name": "X-API-Key"},
"request_params": {"type": "object", "properties": {"city": {"type": "string"}}}
}
],
user_id="user-001",
auto_deploy=True
)
"""
logger.info(
"generating_agent_from_tools",
agent_name=agent_name,
tool_count=len(tools),
user_id=user_id,
auto_deploy=auto_deploy
)
payload = {
"agent_name": agent_name,
"description": description,
"tools": tools,
"user_id": user_id,
"auto_deploy": auto_deploy
}
if tenant_id:
payload["tenant_id"] = tenant_id
return await self._request("POST", "/tools/generate-agent", json=payload)
async def delete_agent(self, agent_name: str) -> Dict[str, Any]:
"""
Delete Agent Pod
Call: DELETE /agents/{agent_name}
Args:
agent_name: Agent name
Returns:
Deletion result, including a message field
"""
logger.info("deleting_agent", name=agent_name)
return await self._request("DELETE", f"/agents/{agent_name}")
async def get_agent_status(self, agent_name: str) -> AgentStatusResult:
"""
Get Agent status
Call: GET /agents/{agent_name}/status
Agent Manager return format (after v2 update):
{
"name": "alice-echo",
"namespace": "ai-agents",
"status": "Running",
"health_status": "healthy",
"created_at": "2026-01-06T09:00:00Z",
"pod_ip": "10.244.1.100",
"host_ip": "192.168.1.10",
"node_name": "node-1",
"labels": {"template": "echo_agent", "user_id": "alice"},
"service_port": 8080,
"access_url": "http://alice-echo.ai-agents.svc.cluster.local:8080",
"containers": [...],
"resources": {...},
"endpoints": [...],
"conditions": [...]
}
Args:
agent_name: Agent name
Returns:
Agent status information, including:
- Basic info: name, namespace, status, health_status
- Network info: pod_ip, host_ip, service_port, access_url, endpoints
- Container status: containers (including ready, restart_count, state, etc.)
- Resource configuration: resources (including requests and limits)
- Pod conditions: conditions
Accessible via properties:
- status.is_healthy: Is it healthy
- status.is_running: Is it running
- status.is_ready: Is it ready
- status.total_restart_count: Total restart count
- status.has_crashed_container: Is there a crashed container
- status.cpu_request/cpu_limit: CPU configuration
- status.memory_request/memory_limit: Memory configuration
"""
response = await self._request("GET", f"/agents/{agent_name}/status")
# Agent Manager 可能返回 {"success": true, "data": {...}} 或直接返回数据
if isinstance(response, dict) and "data" in response:
data = response["data"]
else:
data = response
# 记录返回的原始数据,便于调试
logger.debug(
"agent_status_response",
agent_name=agent_name,
response_keys=list(data.keys()) if isinstance(data, dict) else None,
status=data.get("status") if isinstance(data, dict) else None
)
# Parse container status list
containers = []
for c in data.get("containers", []):
containers.append(ContainerStatus(
name=c.get("name", "unknown"),
ready=c.get("ready", False),
restart_count=c.get("restart_count", 0),
state=c.get("state", "unknown"),
reason=c.get("reason"),
message=c.get("message"),
exit_code=c.get("exit_code"),
started_at=c.get("started_at"),
finished_at=c.get("finished_at")
))
# Parse endpoints (can be list or dict)
endpoints_data = data.get("endpoints")
if isinstance(endpoints_data, dict):
# Old format: dictionary
endpoints = list(endpoints_data.values()) if endpoints_data else None
else:
# New format: list
endpoints = endpoints_data
# ========== 解析 access_info(域名和外网IP) ==========
access_info = data.get("access_info") or {}
external_ip = access_info.get("external_ip")
domain = access_info.get("domain")
domain_url = access_info.get("domain_url")
ip_url = access_info.get("ip_url")
# 优先使用 access_info 中的推荐地址,否则使用旧的 access_url
access_url = access_info.get("recommended") or data.get("access_url")
# ======================================================
# 确保状态有有效值
agent_status = data.get("status", "Pending")
if not agent_status or agent_status.lower() == "unknown":
agent_status = "Pending"
return AgentStatusResult(
name=data.get("name", agent_name),
namespace=data.get("namespace", "ai-agents"),
status=agent_status,
health_status=data.get("health_status", "unknown"),
created_at=data.get("created_at"),
pod_ip=data.get("pod_ip"),
host_ip=data.get("host_ip"),
node_name=data.get("node_name"),
labels=data.get("labels"),
service_port=data.get("service_port"),
access_url=access_url,
containers=containers,
resources=data.get("resources"),
endpoints=endpoints,
conditions=data.get("conditions"),
# Extract template from labels (for compatibility)
template=data.get("labels", {}).get("template") if data.get("labels") else None,
# ========== 新增:访问信息字段 ==========
external_ip=external_ip,
domain=domain,
domain_url=domain_url,
ip_url=ip_url,
)
async def get_agent_metrics(self, agent_name: str) -> AgentMetricsResult:
"""
Get Agent resource usage
Call: GET /agents/{agent_name}/metrics
Agent Manager return format:
{
"name": "alice-echo",
"namespace": "ai-agents",
"requests": {
"cpu": "100m",
"memory": "128Mi"
},
"limits": {
"cpu": "500m",
"memory": "512Mi"
},
"usage": {
"cpu": "14502n",
"memory": "8704Ki"
},
"timestamp": "2026-01-06T05:01:04Z",
"metrics_available": null
}
Args:
agent_name: Agent name
Returns:
Resource usage information, accessible via properties:
Quota information:
- metrics.cpu_limit: CPU limit string (e.g., "500m")
- metrics.memory_limit: Memory limit string (e.g., "512Mi")
- metrics.cpu_request: CPU request string (e.g., "100m")
- metrics.memory_request: Memory request string (e.g., "128Mi")
- metrics.cpu_limit_millicores: CPU limit (millicores)
- metrics.memory_limit_mb: Memory limit (MB)
Real-time usage (requires metrics-server):
- metrics.usage: Real-time resource usage dictionary
- metrics.cpu_usage_current: Current CPU usage string (e.g., "14502n")
- metrics.memory_usage_current: Current memory usage string (e.g., "8704Ki")
- metrics.cpu_usage_current_millicores: Current CPU usage (millicores)
- metrics.memory_usage_current_mb: Current memory usage (MB)
- metrics.cpu_utilization_percent: CPU utilization percentage
- metrics.memory_utilization_percent: Memory utilization percentage
- metrics.has_realtime_metrics: Whether there is real-time data
- metrics.timestamp: Timestamp of metrics data
- metrics.metrics_available: Whether metrics-server is available
Compatibility properties:
- metrics.available: Is it available (based on limits)
"""
data = await self._request("GET", f"/agents/{agent_name}/metrics")
return AgentMetricsResult(
name=data.get("name", agent_name),
namespace=data.get("namespace", "ai-agents"),
requests=data.get("requests", {}),
limits=data.get("limits", {}),
usage=data.get("usage"), # New: Real-time resource usage
timestamp=data.get("timestamp"), # New: metrics timestamp
metrics_available=data.get("metrics_available"), # New: metrics-server availability
resources=data.get("resources", {}) # Compatible with old format
)
async def list_agents(self, template: Optional[str] = None) -> AgentListResult:
"""
List all Agents
Call: GET /agents
Args:
template: Filter by template type (optional)
Returns:
Agent list result
"""
params = {}
if template:
params["template"] = template
data = await self._request("GET", "/agents", params=params)
return AgentListResult(
agents=data.get("agents", []),
count=data.get("count", 0)
)
# ==================== External Data Tool Management ====================
# 外部数据工具管理接口
# Agent Manager 负责生成和存储 Pydantic 工具文件,返回 tool_ref_id 给 MCP-Server
async def generate_external_tool(
self,
name: str,
description: str,
url: str,
method: str,
user_id: str,
tenant_id: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
auth: Optional[Dict[str, Any]] = None,
request_params: Optional[Dict[str, Any]] = None,
request_body: Optional[Dict[str, Any]] = None,
response_mapping: Optional[Dict[str, Any]] = None,
timeout: int = 30,
retry: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
生成外部数据工具
调用: POST /tools/generate
将工具配置发送给 Agent Manager,AM 会:
1. 根据配置生成 Pydantic 工具代码文件
2. 存储工具文件和配置
3. 返回 tool_ref_id(工具标识)
Args:
name: 工具名称
description: 工具描述
url: API 端点 URL
method: HTTP 方法(GET/POST/PUT/DELETE/PATCH)
user_id: 用户 ID
tenant_id: 租户 ID(可选)
headers: 自定义请求头
auth: 认证配置(type, key, in, name 等)
request_params: URL 查询参数定义(JSON Schema 格式)
request_body: 请求体定义(JSON Schema 格式)
response_mapping: 响应字段映射
timeout: 超时时间(秒)
retry: 重试配置
Returns:
{
"success": true,
"tool_ref_id": "tool-xxx-123",
"tool_name": "weather_query_tool",
"status": "active",
"message": "工具生成成功"
}
"""
payload: Dict[str, Any] = {
"name": name,
"description": description,
"url": url,
"method": method,
"user_id": user_id,
}
if tenant_id:
payload["tenant_id"] = tenant_id
if headers:
payload["headers"] = headers
if auth:
payload["auth"] = auth
# Agent Manager 期望的是 input_schema,而不是 request_params/request_body
# input_schema 统一使用 JSON Schema 格式定义工具的输入参数
input_schema = request_params or request_body
if input_schema:
payload["input_schema"] = input_schema
if response_mapping:
payload["response_mapping"] = response_mapping
if timeout:
payload["timeout"] = timeout
if retry:
payload["retry"] = retry
logger.info(
"generating_external_tool",
name=name,
url=url,
method=method,
user_id=user_id,
has_input_schema=input_schema is not None
)
return await self._request("POST", "/external-tools/generate", json=payload)
async def update_external_tool(
self,
tool_ref_id: str,
name: str,
description: str,
url: str,
method: str,
user_id: str,
tenant_id: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
auth: Optional[Dict[str, Any]] = None,
request_params: Optional[Dict[str, Any]] = None,
request_body: Optional[Dict[str, Any]] = None,
response_mapping: Optional[Dict[str, Any]] = None,
timeout: int = 30,
retry: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
更新外部数据工具
调用: PUT /external-tools/{tool_ref_id}
Agent Manager 会重新生成工具文件,可能返回新的 tool_ref_id。
Args:
tool_ref_id: 原工具标识
其他参数同 generate_external_tool
Returns:
{
"success": true,
"tool_ref_id": "tool-xxx-123-v2",
"status": "active",
"message": "工具更新成功"
}
"""
payload: Dict[str, Any] = {
"name": name,
"description": description,
"url": url,
"method": method,
"user_id": user_id,
}
if tenant_id:
payload["tenant_id"] = tenant_id
if headers:
payload["headers"] = headers
if auth:
payload["auth"] = auth
# Agent Manager 期望的是 input_schema,而不是 request_params/request_body
input_schema = request_params or request_body
if input_schema:
payload["input_schema"] = input_schema
if response_mapping:
payload["response_mapping"] = response_mapping
if timeout:
payload["timeout"] = timeout
if retry:
payload["retry"] = retry
logger.info(
"updating_external_tool",
tool_ref_id=tool_ref_id,
name=name,
user_id=user_id,
has_input_schema=input_schema is not None
)
return await self._request("PUT", f"/external-tools/{tool_ref_id}", json=payload)
async def delete_external_tool(self, tool_ref_id: str) -> Dict[str, Any]:
"""
删除外部数据工具
调用: DELETE /external-tools/{tool_ref_id}
Agent Manager 会删除对应的工具文件和配置。
Args:
tool_ref_id: 工具标识
Returns:
{
"success": true,
"message": "工具删除成功"
}
"""
logger.info("deleting_external_tool", tool_ref_id=tool_ref_id)
return await self._request("DELETE", f"/external-tools/{tool_ref_id}")
async def test_external_tool(
self,
tool_ref_id: str,
test_params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
测试外部数据工具连接
调用: POST /external-tools/{tool_ref_id}/test
Agent Manager 会尝试调用工具的 API 并返回测试结果。
Args:
tool_ref_id: 工具标识
test_params: 测试参数(可选)
Returns:
{
"success": true,
"connected": true,
"response_time_ms": 156,
"status_code": 200,
"sample_response": {...}
}
"""
payload = {}
if test_params:
payload["test_params"] = test_params
logger.info("testing_external_tool", tool_ref_id=tool_ref_id)
return await self._request("POST", f"/external-tools/{tool_ref_id}/test", json=payload)
async def create_agent_with_tools(
self,
name: str,
template: str,
tool_refs: List[str],
config: Optional[AgentConfig] = None,
env: Optional[Dict[str, str]] = None
) -> AgentCreateResult:
"""
创建带有外部数据工具的 Agent
调用: POST /external-tools/agents/create-with-tools (Agent Manager v2.0)
Agent Manager 会根据 tool_refs 加载对应的工具文件,部署到 AKS。
Args:
name: Agent 名称
template: Agent 模板(如 echo_agent)
tool_refs: 外部数据工具标识列表
config: 资源配置
env: 环境变量
Returns:
创建结果
"""
payload: Dict[str, Any] = {
"name": name,
"template": template,
"tool_refs": tool_refs
}
if config:
payload["config"] = config.to_dict()
# 构建环境变量
final_env = {"LLM_BASE_URL": LLM_BASE_URL}
if env:
final_env.update(env)
payload["env"] = final_env
logger.info(
"creating_agent_with_tools",
name=name,
template=template,
tool_refs=tool_refs,
config=config.to_dict() if config else None
)
response = await self._request("POST", "/external-tools/agents/create-with-tools", json=payload)
# 检查业务逻辑是否成功(agent-manager 可能返回 HTTP 200 但 success=false)
if isinstance(response, dict) and response.get("success") is False:
error_code = response.get("error", "unknown_error")
error_message = response.get("message", "创建 Agent 失败")
logger.error(
"create_agent_with_tools_failed",
name=name,
error_code=error_code,
error_message=error_message
)
raise AgentManagerError(
message=error_message,
status_code=400,
detail={"error": error_code, "message": error_message}
)
# Agent Manager 返回格式: {"success": true, "data": {...}} 或直接返回数据
# 需要兼容两种格式
if isinstance(response, dict) and "data" in response:
data = response["data"]
else:
data = response
# 确保状态字段有默认值,避免 unknown
agent_status = data.get("status", "Pending")
if not agent_status or agent_status.lower() == "unknown":
# 如果 Agent 已经创建成功,但状态未知,默认设为 Pending
agent_status = "Pending"
logger.info(
"agent_with_tools_created",
name=name,
status=agent_status,
response_keys=list(data.keys()) if isinstance(data, dict) else None
)
return AgentCreateResult(
name=data.get("name", name),
namespace=data.get("namespace", "ai-agents"),
status=agent_status,
created_at=data.get("created_at", ""),
template=data.get("template", template),
service_port=data.get("service_port"),
access_info=data.get("access_info"),
pod_id=data.get("pod_id"),
pod_ip=data.get("pod_ip"),
host_ip=data.get("host_ip"),
node_name=data.get("node_name"),
owner_info=data.get("owner_info")
)
# ==================== Unimplemented Interfaces (Not yet provided by Agent Manager) ====================
# The interfaces called by the following methods are not yet implemented in Agent Manager
# Method signatures are retained for future extension, but will raise NotImplementedError when called
async def scale_agent(
self,
agent_name: str,
replicas: Optional[int] = None,
cpu_request: Optional[str] = None,
cpu_limit: Optional[str] = None,
memory_request: Optional[str] = None,
memory_limit: Optional[str] = None
) -> Dict[str, Any]:
"""
Scale Agent (to be implemented)
Expected call: PATCH /agents/{agent_name}/scale
Note: Agent Manager has not yet implemented this interface
"""
raise NotImplementedError(
"Agent Manager has not yet implemented the PATCH /agents/{name}/scale interface"
)
async def get_agent_logs(
self,
agent_name: str,
tail_lines: int = 100,
since_seconds: Optional[int] = None
) -> str:
"""
Get Agent logs (to be implemented)
Expected call: GET /agents/{agent_name}/logs
Note: Agent Manager has not yet implemented this interface
"""
raise NotImplementedError(
"Agent Manager has not yet implemented the GET /agents/{name}/logs interface"
)
async def restart_agent(self, agent_name: str) -> Dict[str, Any]:
"""
Restart Agent (to be implemented)
Expected call: POST /agents/{agent_name}/restart
Note: Agent Manager has not yet implemented this interface
"""
raise NotImplementedError(
"Agent Manager has not yet implemented the POST /agents/{name}/restart interface"
)
async def get_resource_stats(self) -> Dict[str, Any]:
"""
Get resource statistics (to be implemented)
Expected call: GET /resources/stats
Note: Agent Manager has not yet implemented this interface
"""
raise NotImplementedError(
"Agent Manager has not yet implemented the GET /resources/stats interface"
)
async def get_user_resources(self, user_id: str) -> Dict[str, Any]:
"""
Get user resource usage statistics (to be implemented)
Expected call: GET /resources/stats/by-user/{user_id}
Note: Agent Manager has not yet implemented this interface
"""
raise NotImplementedError(
"Agent Manager has not yet implemented the GET /resources/stats/by-user/{user_id} interface"
)
async def get_channel_resources(self, channel_id: str) -> Dict[str, Any]:
"""
Get channel resource usage statistics (to be implemented)
Expected call: GET /resources/stats/by-channel/{channel_id}
Note: Agent Manager has not yet implemented this interface
"""
raise NotImplementedError(
"Agent Manager has not yet implemented the GET /resources/stats/by-channel/{channel_id} interface"
)
# Global client instance
_agent_manager_client: Optional[AgentManagerClient] = None
def get_agent_manager_client() -> AgentManagerClient:
"""Get global Agent Manager client instance"""
global _agent_manager_client
if _agent_manager_client is None:
_agent_manager_client = AgentManagerClient()
return _agent_manager_client
async def close_agent_manager_client():
"""Close global client"""
global _agent_manager_client
if _agent_manager_client:
await _agent_manager_client.close()
_agent_manager_client = None