Phase 4.2+4.3: YAML workflow spec + ACA protocol abstraction (v2026.07.19.10)
CI / gate (push) Has been cancelled
CI / gate (push) Has been cancelled
This commit is contained in:
1 parent
c716dab851
commit
79c27795a8
9 files changed
+936
-1
No files matched your search
@@ -0,0 +1,293 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user