"""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"" # ── 注册表 ─────────────────────────────────────────────────────────────────── 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