294 lines
12 KiB
Python
294 lines
12 KiB
Python
"""Phase 4.3: ACA (Agent Communication Abstraction) 协议抽象层.
|
||
|
||
设计目标:
|
||
- 定义统一对外工具协议接口,隔离 MCP / A2A / 自定义协议的具体实现。
|
||
- 防止协议绑架:未来 MCP 升级或 A2A 替代 MCP 时,只需增删适配器,不改业务代码。
|
||
- 每个协议适配器被注册为 "protocol adapter",按名称发现。
|
||
|
||
接口:
|
||
ACAProtocol
|
||
name: str
|
||
version: str
|
||
capabilities: list[str]
|
||
async list_tools() -> list[dict] # [{name, description, inputSchema}]
|
||
async call_tool(tool_name, arguments) -> str
|
||
async health_check() -> bool
|
||
|
||
ProtocolRegistry
|
||
register(adapter: ACAProtocol)
|
||
get(name: str) -> ACAProtocol
|
||
list_all() -> list[ACAProtocol]
|
||
async list_all_tools() -> list[dict] # 聚合所有协议的 tools,带 protocol tag
|
||
async call_tool(protocol_name, tool_name, args) -> str
|
||
|
||
使用示例:
|
||
from app.core.aca import registry
|
||
tools = await registry.list_all_tools()
|
||
# tools: [{"name":"search","protocol":"mcp","description":"..."}, ...]
|
||
result = await registry.call_tool("mcp", "search", {"query":"..."})
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import abc
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger("aiflow.aca")
|
||
|
||
# ── 协议接口 ─────────────────────────────────────────────────────────────────
|
||
|
||
class ACAProtocol(abc.ABC):
|
||
name: str = ""
|
||
version: str = "1.0"
|
||
capabilities: list[str] = []
|
||
|
||
@abc.abstractmethod
|
||
async def list_tools(self) -> list[dict]:
|
||
"""返回 [{name, description, inputSchema}]。"""
|
||
|
||
@abc.abstractmethod
|
||
async def call_tool(self, tool_name: str, arguments: dict) -> str:
|
||
"""调用工具并返回文本结果。"""
|
||
|
||
async def health_check(self) -> bool:
|
||
"""健康检查,默认 True。"""
|
||
return True
|
||
|
||
def __repr__(self):
|
||
return f"<ACA {self.name} v{self.version}>"
|
||
|
||
|
||
# ── 注册表 ───────────────────────────────────────────────────────────────────
|
||
|
||
class ProtocolRegistry:
|
||
"""全局协议注册表,单例。"""
|
||
|
||
def __init__(self):
|
||
self._adapters: dict[str, ACAProtocol] = {}
|
||
|
||
def register(self, adapter: ACAProtocol):
|
||
if not adapter.name:
|
||
raise ValueError("protocol adapter must have a name")
|
||
self._adapters[adapter.name] = adapter
|
||
logger.info("ACA registry: registered %s", adapter)
|
||
|
||
def get(self, name: str) -> ACAProtocol | None:
|
||
return self._adapters.get(name)
|
||
|
||
def list_all(self) -> list[ACAProtocol]:
|
||
return list(self._adapters.values())
|
||
|
||
async def list_all_tools(self) -> list[dict]:
|
||
"""聚合所有已注册协议的 tools,每个 tool 标记 protocol。"""
|
||
tasks = [self._list_with_name(a) for a in self._adapters.values()]
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
flat: list[dict] = []
|
||
for rlist in results:
|
||
if isinstance(rlist, list):
|
||
flat.extend(rlist)
|
||
return flat
|
||
|
||
async def _list_with_name(self, adapter: ACAProtocol) -> list[dict]:
|
||
try:
|
||
tools = await adapter.list_tools()
|
||
for t in tools:
|
||
t["protocol"] = adapter.name
|
||
return tools
|
||
except Exception as e:
|
||
logger.warning("ACA: list_tools failed for %s: %s", adapter.name, e)
|
||
return []
|
||
|
||
async def call_tool(self, protocol_name: str, tool_name: str, arguments: dict) -> str:
|
||
adapter = self._adapters.get(protocol_name)
|
||
if not adapter:
|
||
raise ValueError(f"协议 {protocol_name} 未注册")
|
||
return await adapter.call_tool(tool_name, arguments)
|
||
|
||
|
||
# 全局单例
|
||
registry = ProtocolRegistry()
|
||
|
||
|
||
# ── MCP 适配器 ───────────────────────────────────────────────────────────────
|
||
|
||
class McpProtocolAdapter(ACAProtocol):
|
||
"""包装平台现有的 MCP 客户端 (app.mcp_client),提供统一协议接口。"""
|
||
|
||
def __init__(self, server_configs: list[dict] = None):
|
||
self.name = "mcp"
|
||
self.version = "1.0"
|
||
self.capabilities = ["tools", "resources", "prompts"]
|
||
self._server_configs = server_configs or []
|
||
self._tools_cache: list[dict] = []
|
||
self._cache_ts: float = 0
|
||
|
||
async def list_tools(self) -> list[dict]:
|
||
# Use cached tools if available (TTL 30s)
|
||
import time
|
||
if self._tools_cache and (time.time() - self._cache_ts) < 30:
|
||
return [dict(t) for t in self._tools_cache]
|
||
from app.mcp_client import list_mcp_tools
|
||
all_tools: list[dict] = []
|
||
for server in self._server_configs:
|
||
try:
|
||
tools = await list_mcp_tools(server)
|
||
for t in tools:
|
||
t["_mcp_server_id"] = server.get("id", "")
|
||
t["_mcp_server_name"] = server.get("name", "")
|
||
all_tools.extend(tools)
|
||
except Exception as e:
|
||
logger.warning("MCP list_tools failed for %s: %s", server.get("name"), e)
|
||
self._tools_cache = [dict(t) for t in all_tools]
|
||
self._cache_ts = time.time()
|
||
return list(self._tools_cache)
|
||
|
||
async def call_tool(self, tool_name: str, arguments: dict) -> str:
|
||
from app.mcp_client import call_mcp_tool
|
||
# Find the server that has this tool (check cache first)
|
||
for t in self._tools_cache:
|
||
if t.get("name") == tool_name:
|
||
sid = t.get("_mcp_server_id", "")
|
||
server = next((s for s in self._server_configs if s.get("id") == sid), None)
|
||
if server:
|
||
return await call_mcp_tool(server, tool_name, arguments)
|
||
# Fallback: try first available server
|
||
if self._server_configs:
|
||
return await call_mcp_tool(self._server_configs[0], tool_name, arguments)
|
||
return json.dumps({"error": "No MCP server configured for tool: " + tool_name})
|
||
|
||
async def health_check(self) -> bool:
|
||
# Check if any MCP server is connected
|
||
try:
|
||
tools = await self.list_tools()
|
||
return len(tools) > 0
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
# ── A2A 适配器 ───────────────────────────────────────────────────────────────
|
||
|
||
class A2aProtocolAdapter(ACAProtocol):
|
||
"""A2A (Agent-to-Agent) 协议适配器。
|
||
|
||
通过标准 JSON-RPC 与远端 Agent 通信。端点需实现:
|
||
- POST /a2a/tools → 返回工具列表
|
||
- POST /a2a/call → 调用工具
|
||
|
||
A2A 规范参考: agentcommunicationprotocol.dev
|
||
"""
|
||
|
||
def __init__(self, endpoint: str, api_key: str = None, timeout: float = 30):
|
||
self.name = "a2a"
|
||
self.version = "0.9"
|
||
self.capabilities = ["tools"]
|
||
self.endpoint = endpoint.rstrip("/")
|
||
self.api_key = api_key
|
||
self.timeout = timeout
|
||
|
||
async def list_tools(self) -> list[dict]:
|
||
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
|
||
headers = self._headers()
|
||
resp = await client.post(f"{self.endpoint}/a2a/tools", headers=headers, json={})
|
||
if resp.status_code != 200:
|
||
logger.warning("A2A list_tools %s HTTP %s: %s", self.endpoint, resp.status_code, resp.text[:200])
|
||
return []
|
||
data = resp.json()
|
||
return data.get("tools") or data.get("data", {}).get("tools") or []
|
||
|
||
async def call_tool(self, tool_name: str, arguments: dict) -> str:
|
||
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
|
||
headers = self._headers()
|
||
body = {
|
||
"jsonrpc": "2.0",
|
||
"method": "tools/call",
|
||
"params": {"name": tool_name, "arguments": arguments},
|
||
"id": 1,
|
||
}
|
||
resp = await client.post(f"{self.endpoint}/a2a/call", headers=headers, json=body)
|
||
if resp.status_code != 200:
|
||
return json.dumps({"error": f"A2A HTTP {resp.status_code}: {resp.text[:300]}"})
|
||
data = resp.json()
|
||
if "error" in data:
|
||
return json.dumps({"error": data["error"]})
|
||
result = data.get("result")
|
||
if isinstance(result, dict):
|
||
return result.get("content", json.dumps(result))
|
||
return str(result) if result is not None else ""
|
||
|
||
async def health_check(self) -> bool:
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10, trust_env=False) as client:
|
||
headers = self._headers()
|
||
resp = await client.get(f"{self.endpoint}/a2a/health", headers=headers)
|
||
return resp.status_code == 200
|
||
except Exception:
|
||
return False
|
||
|
||
def _headers(self) -> dict:
|
||
h = {"Content-Type": "application/json"}
|
||
if self.api_key:
|
||
h["Authorization"] = f"Bearer {self.api_key}"
|
||
return h
|
||
|
||
|
||
# ── 启动时初始化 ─────────────────────────────────────────────────────────────
|
||
|
||
def init_aca_registry():
|
||
"""在应用启动时调用,注册所有协议适配器到全局 registry。"""
|
||
from app.database import SessionLocal
|
||
from app.models import MCPServer
|
||
|
||
# 注册 MCP 适配器(从 DB 读取所有 enabled MCP server 配置)
|
||
try:
|
||
db = SessionLocal()
|
||
servers = db.query(MCPServer).filter(MCPServer.is_enabled == True).all()
|
||
server_configs = []
|
||
for s in servers:
|
||
server_configs.append({
|
||
"id": s.id,
|
||
"name": s.name,
|
||
"transport_type": s.transport_type,
|
||
"command": s.command,
|
||
"args": json.loads(s.args) if isinstance(s.args, str) else s.args or [],
|
||
"env_vars": json.loads(s.env_vars) if isinstance(s.env_vars, str) else s.env_vars or {},
|
||
"url": s.url,
|
||
"headers": json.loads(s.headers) if isinstance(s.headers, str) else s.headers or {},
|
||
})
|
||
db.close()
|
||
if server_configs:
|
||
registry.register(McpProtocolAdapter(server_configs))
|
||
logger.info("ACA: MCP adapter registered with %d servers", len(server_configs))
|
||
except Exception as e:
|
||
logger.warning("ACA: failed to init MCP adapter: %s", e)
|
||
|
||
# 注册 A2A 适配器(从配置或 DB 读取 A2A endpoint)
|
||
# A2A endpoint 可以从 site_settings 读取,暂无则留空(以后台配置驱动)
|
||
a2a_endpoint = _get_a2a_config()
|
||
if a2a_endpoint:
|
||
registry.register(A2aProtocolAdapter(
|
||
endpoint=a2a_endpoint.get("endpoint", ""),
|
||
api_key=a2a_endpoint.get("api_key"),
|
||
))
|
||
logger.info("ACA: A2A adapter registered at %s", a2a_endpoint.get("endpoint"))
|
||
|
||
|
||
def _get_a2a_config() -> dict | None:
|
||
"""从 site_settings 读取 A2A 全局配置。"""
|
||
try:
|
||
from app.database import SessionLocal
|
||
from app.models import SiteSetting
|
||
db = SessionLocal()
|
||
setting = db.query(SiteSetting).filter(SiteSetting.key == "aca_a2a_endpoint").first()
|
||
db.close()
|
||
if setting and setting.value:
|
||
val = json.loads(setting.value) if isinstance(setting.value, str) else setting.value
|
||
if isinstance(val, dict) and val.get("endpoint"):
|
||
return val
|
||
except Exception:
|
||
pass
|
||
return None
|