mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-21 20:20:05 +08:00
✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)
* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 - 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片 - 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式 - 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统 - 【新增】引入基于 Docker 的安全代码执行沙箱环境 - 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务 - 【新增】引入输入输出安全合规护栏与自愈反思机制 - 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等) - 【优化】优化日志脱敏与 Token 预估机制 - 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令 * 🔧 chore(deps): 更新项目依赖与配置 - 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt - 在 pyright 配置中设置 reportMissingImports 为 none - 调整 .gitignore 中 resources 目录的忽略规则 * ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出 - 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束 - 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用 - 调整指令处理器(Directive)的返回值为 `tool_res.output` - 修复部分类型检查警告并优化联合类型判断语法 * ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制 - 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用 - 移除通过 `context.state` 传递魔术变量的隐式控制流设计 - 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult` - 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令 - 补全 `MessageBuilder` 中部分核心方法的文档注释 * 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑 -【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值 -【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题 -【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求 -【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建 -【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常 * 📝 docs(core): 补充核心模块初始化方法的文档注释 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
bdc1374848
commit
80fc5b86a7
@@ -0,0 +1,3 @@
|
||||
from .provider import GlobalMCPProvider, MCPSource, mcp_provider
|
||||
|
||||
__all__ = ["GlobalMCPProvider", "MCPSource", "mcp_provider"]
|
||||
@@ -0,0 +1,366 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.configs.path_config import DATA_PATH
|
||||
from zhenxun.services.ai.core.protocols.tool import (
|
||||
ToolExecutable,
|
||||
ToolProvider,
|
||||
ToolResolvable,
|
||||
)
|
||||
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
|
||||
from zhenxun.services.ai.tools.models import ResolvedToolPayload
|
||||
from zhenxun.services.ai.tools.providers.mcp.toolkit import MCPToolkit
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import model_dump, model_validate, model_validator
|
||||
|
||||
MCP_PATH = DATA_PATH / "ai" / "mcp.json"
|
||||
|
||||
|
||||
class MCPServerConfig(BaseModel):
|
||||
transport: Literal["stdio", "sse", "streamable-http", "sandbox_proxy"] = Field(
|
||||
default="stdio"
|
||||
)
|
||||
"""传输协议类型:stdio / sse / streamable-http / sandbox_proxy"""
|
||||
url: str | None = Field(default=None)
|
||||
"""远端地址(用于 sse 或 streamable-http)"""
|
||||
timeout: int = Field(default=30)
|
||||
"""请求超时时间(秒)"""
|
||||
command: str | None = None
|
||||
"""启动命令(用于 stdio 或 sandbox_proxy)"""
|
||||
args: list[str] = Field(default_factory=list)
|
||||
"""命令参数列表"""
|
||||
env: dict[str, str] | None = None
|
||||
"""启动子进程的环境变量(可选)"""
|
||||
cwd: str | None = Field(default=None)
|
||||
"""进程的工作目录"""
|
||||
install_command: str | None = Field(default=None)
|
||||
"""首次运行前的安装/预热命令"""
|
||||
description: str | None = None
|
||||
"""服务描述信息(用于配置可读性与展示)"""
|
||||
enabled: bool = Field(default=True)
|
||||
"""是否全局启用"""
|
||||
admin_level: int = Field(default=0)
|
||||
"""执行该服务器工具所需的群管等级"""
|
||||
sandbox_blueprint: SandboxBlueprint | None = Field(default=None)
|
||||
"""沙箱环境装配配置(用于 sandbox_proxy 自动处理依赖)"""
|
||||
|
||||
|
||||
class MCPToolsConfig(BaseModel):
|
||||
mcpServers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GlobalMCPProvider(ToolProvider):
|
||||
def __init__(self):
|
||||
self._config: MCPToolsConfig | None = None
|
||||
self._toolkits: dict[str, MCPToolkit] = {}
|
||||
self._discovery_lock = asyncio.Lock()
|
||||
self._discovered_tools: dict[str, ToolExecutable] | None = None
|
||||
self.env_provider: Callable[[Any], dict[str, str]] | None = None
|
||||
self.header_provider: Callable[[Any], dict[str, str]] | None = None
|
||||
self._code_registered_servers: dict[str, tuple[MCPServerConfig, bool]] = {}
|
||||
|
||||
def register_server(
|
||||
self, name: str, config: MCPServerConfig, persist: bool = False
|
||||
) -> None:
|
||||
"""代码优先:动态注册 MCP 服务 (支持无痕内存级注册与热重载)"""
|
||||
self._code_registered_servers[name] = (config, persist)
|
||||
|
||||
if self._config is not None:
|
||||
if persist:
|
||||
if name not in self._config.mcpServers:
|
||||
self._config.mcpServers[name] = config
|
||||
self._save_config()
|
||||
else:
|
||||
json_conf = self._config.mcpServers[name]
|
||||
code_dict = model_dump(config, exclude_unset=True)
|
||||
json_dict = model_dump(json_conf, exclude_unset=True)
|
||||
merged_dict = {**code_dict, **json_dict}
|
||||
merged_conf = model_validate(MCPServerConfig, merged_dict)
|
||||
|
||||
if model_dump(merged_conf) != model_dump(json_conf):
|
||||
self._config.mcpServers[name] = merged_conf
|
||||
self._save_config()
|
||||
|
||||
if name not in self._toolkits:
|
||||
self._setup_toolkit(name, config)
|
||||
logger.info(
|
||||
f"🔄 MCP 代理 '{name}' 已完成动态热重载注册 (持久化: {persist})"
|
||||
)
|
||||
|
||||
async def unregister_server(self, name: str) -> None:
|
||||
"""动态注销 MCP 服务,关闭底层进程并从配置文件中移除"""
|
||||
if name in self._code_registered_servers:
|
||||
self._code_registered_servers.pop(name)
|
||||
if self._config and name in self._config.mcpServers:
|
||||
self._config.mcpServers.pop(name)
|
||||
self._save_config()
|
||||
if tk := self._toolkits.pop(name, None):
|
||||
await tk.close()
|
||||
logger.debug(f"🛑 MCP 代理 '{name}' 已注销并销毁所有连接")
|
||||
|
||||
def _save_config(self) -> None:
|
||||
"""将当前配置持久化到 JSON 文件"""
|
||||
if not self._config:
|
||||
return
|
||||
try:
|
||||
with MCP_PATH.open("w", encoding="utf-8") as f:
|
||||
data = model_dump(self._config, exclude_unset=True)
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"保存 MCP 配置失败: {e}", e=e)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self._config is not None:
|
||||
return
|
||||
|
||||
if not MCP_PATH.exists():
|
||||
MCP_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._config = MCPToolsConfig(
|
||||
mcpServers={
|
||||
"amap-maps": MCPServerConfig(
|
||||
command="npx",
|
||||
args=["-y", "@amap/amap-maps-mcp-server"],
|
||||
env={"AMAP_MAPS_API_KEY": "Your_API_Key"},
|
||||
enabled=False,
|
||||
),
|
||||
"bilibili-search": MCPServerConfig(
|
||||
command="npx",
|
||||
args=["bilibili-mcp"],
|
||||
),
|
||||
"fetch": MCPServerConfig(
|
||||
command="uvx",
|
||||
args=["mcp-server-fetch"],
|
||||
),
|
||||
"bingcn": MCPServerConfig(
|
||||
command="npx",
|
||||
args=["bing-cn-mcp"],
|
||||
),
|
||||
}
|
||||
)
|
||||
with MCP_PATH.open("w", encoding="utf-8") as f:
|
||||
data = model_dump(self._config, exclude_unset=True)
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
try:
|
||||
with MCP_PATH.open("r", encoding="utf-8") as f:
|
||||
self._config = model_validate(MCPToolsConfig, json.load(f))
|
||||
except Exception as e:
|
||||
logger.error(f"加载 MCP 工具配置文件失败: {e}", e=e)
|
||||
self._config = MCPToolsConfig()
|
||||
|
||||
config_changed = False
|
||||
for name, (code_conf, persist) in self._code_registered_servers.items():
|
||||
if persist:
|
||||
if name not in self._config.mcpServers:
|
||||
self._config.mcpServers[name] = code_conf
|
||||
config_changed = True
|
||||
else:
|
||||
json_conf = self._config.mcpServers[name]
|
||||
code_dict = model_dump(code_conf, exclude_unset=True)
|
||||
json_dict = model_dump(json_conf, exclude_unset=True)
|
||||
merged_dict = {**code_dict, **json_dict}
|
||||
merged_conf = model_validate(MCPServerConfig, merged_dict)
|
||||
|
||||
if model_dump(merged_conf) != model_dump(json_conf):
|
||||
self._config.mcpServers[name] = merged_conf
|
||||
config_changed = True
|
||||
|
||||
if config_changed or not MCP_PATH.exists():
|
||||
self._save_config()
|
||||
|
||||
for name, conf in self._config.mcpServers.items():
|
||||
self._setup_toolkit(name, conf)
|
||||
|
||||
for name, (code_conf, persist) in self._code_registered_servers.items():
|
||||
if not persist and name not in self._toolkits:
|
||||
self._setup_toolkit(name, code_conf)
|
||||
|
||||
def _setup_toolkit(self, name: str, conf: MCPServerConfig) -> None:
|
||||
"""辅助方法:装载单个 MCP Toolkit"""
|
||||
clean_name = re.sub(r"[^a-zA-Z0-9]", "_", name)
|
||||
prefix = f"mcp_{clean_name}_"
|
||||
self._toolkits[name] = MCPToolkit(
|
||||
server_name=name,
|
||||
prefix=prefix,
|
||||
transport=conf.transport,
|
||||
command=conf.command,
|
||||
args=conf.args,
|
||||
url=conf.url,
|
||||
env=conf.env,
|
||||
cwd=conf.cwd,
|
||||
install_command=conf.install_command,
|
||||
timeout=conf.timeout,
|
||||
admin_level=conf.admin_level,
|
||||
header_provider=self.header_provider,
|
||||
env_provider=self.env_provider,
|
||||
sandbox_blueprint=conf.sandbox_blueprint,
|
||||
)
|
||||
|
||||
async def shutdown(self):
|
||||
for tk in self._toolkits.values():
|
||||
await tk.close()
|
||||
self._toolkits.clear()
|
||||
|
||||
async def get_tools_for_server(self, server_name: str) -> dict[str, ToolExecutable]:
|
||||
return await self.discover_tools(allowed_servers=[server_name])
|
||||
|
||||
async def discover_tools(
|
||||
self,
|
||||
allowed_servers: list[str] | None = None,
|
||||
excluded_servers: list[str] | None = None,
|
||||
) -> dict[str, ToolExecutable]:
|
||||
async with self._discovery_lock:
|
||||
if not self._config:
|
||||
await self.initialize()
|
||||
|
||||
if not self._config:
|
||||
return {}
|
||||
|
||||
if (
|
||||
self._discovered_tools is not None
|
||||
and allowed_servers is None
|
||||
and excluded_servers is None
|
||||
):
|
||||
return self._discovered_tools
|
||||
|
||||
servers_to_query: list[str] = []
|
||||
for name, conf in self._config.mcpServers.items():
|
||||
if allowed_servers and name not in allowed_servers:
|
||||
continue
|
||||
if excluded_servers and name in excluded_servers:
|
||||
continue
|
||||
if not conf.enabled and not allowed_servers:
|
||||
continue
|
||||
servers_to_query.append(name)
|
||||
|
||||
for name, (conf, persist) in self._code_registered_servers.items():
|
||||
if persist:
|
||||
continue
|
||||
if allowed_servers and name not in allowed_servers:
|
||||
continue
|
||||
if excluded_servers and name in excluded_servers:
|
||||
continue
|
||||
if not conf.enabled and not allowed_servers:
|
||||
continue
|
||||
if name not in servers_to_query:
|
||||
servers_to_query.append(name)
|
||||
|
||||
all_tools: dict[str, ToolExecutable] = {}
|
||||
tasks = []
|
||||
for s_name in servers_to_query:
|
||||
if tk := self._toolkits.get(s_name):
|
||||
tasks.append(tk.get_tools())
|
||||
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for res in results:
|
||||
if isinstance(res, dict):
|
||||
for name, t in res.items():
|
||||
all_tools[name] = t
|
||||
elif isinstance(res, list):
|
||||
for t in res:
|
||||
all_tools[t.name] = t
|
||||
elif isinstance(res, Exception):
|
||||
logger.error(f"MCP 并发发现工具失败: {res}")
|
||||
|
||||
if allowed_servers is None and excluded_servers is None:
|
||||
self._discovered_tools = all_tools
|
||||
return all_tools
|
||||
|
||||
async def get_tool_executable(
|
||||
self, name: str, config: dict[str, Any]
|
||||
) -> ToolExecutable | None:
|
||||
_ = name, config
|
||||
return None
|
||||
|
||||
|
||||
class MCPSource(BaseModel):
|
||||
"""显式定义的 MCP 工具源 (动态解析器)"""
|
||||
|
||||
server_name: str | None = None
|
||||
"""目标 MCP 服务名(走全局注册表模式时使用)"""
|
||||
namespace: str | None = None
|
||||
"""可选命名空间标识(用于上层路由或分组)"""
|
||||
config: MCPServerConfig | None = None
|
||||
"""内联临时配置 (MCPServerConfig 实例)"""
|
||||
|
||||
fetch_all_enabled: bool = Field(default=False)
|
||||
"""内部标识:是否拉取所有已启用的 MCP 服务"""
|
||||
exclude_servers: list[str] | None = Field(default=None)
|
||||
"""排除的服务名列表(仅当 fetch_all_enabled 为 True 时生效)"""
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.server_name, self.namespace))
|
||||
|
||||
@classmethod
|
||||
def all_enabled(cls, exclude: list[str] | None = None) -> "MCPSource":
|
||||
"""
|
||||
声明式获取所有已启用的 MCP 服务器下的工具。
|
||||
|
||||
参数:
|
||||
exclude: 需要显式排除的 MCP 服务名称列表。
|
||||
"""
|
||||
return cls(fetch_all_enabled=True, exclude_servers=exclude)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_source(self) -> "MCPSource":
|
||||
if not self.server_name and not self.config and not self.fetch_all_enabled:
|
||||
raise ValueError(
|
||||
"MCPSource 必须提供 server_name 或 config,或者开启 fetch_all_enabled"
|
||||
)
|
||||
return self
|
||||
|
||||
async def resolve(self, context: Any | None = None) -> Any:
|
||||
tools = []
|
||||
if self.fetch_all_enabled:
|
||||
server_tools_dict = await mcp_provider.discover_tools(
|
||||
excluded_servers=self.exclude_servers
|
||||
)
|
||||
for t in server_tools_dict.values():
|
||||
p = await cast(ToolResolvable, t).resolve(context)
|
||||
if p:
|
||||
tools.extend(p.tools)
|
||||
return ResolvedToolPayload(tools=tools)
|
||||
|
||||
elif self.config:
|
||||
import uuid
|
||||
|
||||
temp_server_name = self.server_name or f"inline_mcp_{uuid.uuid4().hex[:8]}"
|
||||
inline_toolkit = MCPToolkit(
|
||||
server_name=temp_server_name,
|
||||
transport=self.config.transport,
|
||||
command=self.config.command,
|
||||
args=self.config.args,
|
||||
url=self.config.url,
|
||||
env=self.config.env,
|
||||
cwd=self.config.cwd,
|
||||
install_command=self.config.install_command,
|
||||
timeout=self.config.timeout,
|
||||
admin_level=self.config.admin_level,
|
||||
sandbox_blueprint=self.config.sandbox_blueprint,
|
||||
)
|
||||
payload = await inline_toolkit.resolve(context)
|
||||
payload.toolkits.insert(0, inline_toolkit)
|
||||
return payload
|
||||
|
||||
else:
|
||||
assert self.server_name is not None
|
||||
server_tools = await mcp_provider.get_tools_for_server(self.server_name)
|
||||
for t in server_tools.values():
|
||||
p = await cast(ToolResolvable, t).resolve(context)
|
||||
if p:
|
||||
tools.extend(p.tools)
|
||||
|
||||
return ResolvedToolPayload(tools=tools)
|
||||
|
||||
|
||||
mcp_provider = GlobalMCPProvider()
|
||||
|
||||
__all__ = ["GlobalMCPProvider", "MCPSource", "mcp_provider"]
|
||||
@@ -0,0 +1,612 @@
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
import re
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import anyio
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
import httpx
|
||||
from mcp import ClientSession # type: ignore
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.shared.message import SessionMessage
|
||||
from pydantic import ValidationError
|
||||
|
||||
from zhenxun.services.ai.core.models import ToolDefinition
|
||||
from zhenxun.services.ai.core.stream_events import UserCustomEvent
|
||||
from zhenxun.services.ai.run import RunContext
|
||||
from zhenxun.services.ai.sandbox.addons.base import BaseMcpProxyExtension
|
||||
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
|
||||
from zhenxun.services.ai.tools.core.tool import BaseTool
|
||||
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
|
||||
from zhenxun.services.ai.tools.models import ToolkitConfig, ToolResult
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.lifespan import LifespanManager
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
_MESSAGE_START_CHARS = {"{", "["}
|
||||
_LITERAL_PREFIXES: tuple[str, ...] = ("true", "false", "null")
|
||||
|
||||
|
||||
def _should_ignore_exception(exc: Exception) -> bool:
|
||||
"""
|
||||
判断该异常是否是由非 JSON 的脏数据标准输出引起的。
|
||||
如果是脏数据,则可以安全忽略。
|
||||
"""
|
||||
if not isinstance(exc, ValidationError):
|
||||
return False
|
||||
|
||||
errors = exc.errors()
|
||||
first = next(iter(errors), None)
|
||||
if not first or first.get("type") != "json_invalid":
|
||||
return False
|
||||
|
||||
input_value = first.get("input")
|
||||
if not isinstance(input_value, str):
|
||||
return False
|
||||
|
||||
stripped = input_value.strip()
|
||||
if not stripped:
|
||||
return True
|
||||
|
||||
first_char = stripped[0]
|
||||
lowered = stripped.lower()
|
||||
|
||||
if first_char in _MESSAGE_START_CHARS or any(
|
||||
lowered.startswith(prefix) for prefix in _LITERAL_PREFIXES
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def filtered_stdio_client(
|
||||
server_name: str, server: StdioServerParameters
|
||||
) -> AsyncGenerator[
|
||||
tuple[
|
||||
MemoryObjectReceiveStream[SessionMessage | Exception],
|
||||
MemoryObjectSendStream[SessionMessage],
|
||||
],
|
||||
None,
|
||||
]:
|
||||
"""
|
||||
包裹官方的 stdio_client,拦截并过滤掉非 JSON 格式的标准输出噪音。
|
||||
"""
|
||||
async with stdio_client(server=server) as (read_stream, write_stream):
|
||||
filtered_send, filtered_recv = anyio.create_memory_object_stream[
|
||||
SessionMessage | Exception
|
||||
](0)
|
||||
|
||||
async def _forward_stdout() -> None:
|
||||
try:
|
||||
async with read_stream:
|
||||
async for item in read_stream:
|
||||
if isinstance(item, Exception) and _should_ignore_exception(
|
||||
item
|
||||
):
|
||||
if isinstance(item, ValidationError):
|
||||
err_input = item.errors()[0].get("input", "")
|
||||
logger.debug(
|
||||
f"🔇 [MCP Stdout Filter] {server_name} 忽略脏数据: "
|
||||
f"{str(err_input)[:100].strip()}..."
|
||||
)
|
||||
continue
|
||||
await filtered_send.send(item)
|
||||
except anyio.ClosedResourceError:
|
||||
pass
|
||||
finally:
|
||||
await filtered_send.aclose()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(_forward_stdout)
|
||||
try:
|
||||
yield filtered_recv, write_stream
|
||||
finally:
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
|
||||
class MCPRemoteTool(BaseTool):
|
||||
"""远端 MCP 工具在本地的代理对象"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
original_tool_name: str,
|
||||
description: str,
|
||||
parameters: dict,
|
||||
toolkit: "MCPToolkit",
|
||||
):
|
||||
super().__init__(name=name, description=description)
|
||||
self.original_tool_name = original_tool_name
|
||||
self.parameters = parameters
|
||||
self.toolkit = toolkit
|
||||
self.parent_toolkit = toolkit
|
||||
self.args_schema = None
|
||||
self.metadata = (
|
||||
{"admin_level": toolkit.admin_level} if toolkit.admin_level > 0 else {}
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_ttl(self) -> float:
|
||||
"""动态获取当前最新的 TTL 配置,支持无缝热重载"""
|
||||
try:
|
||||
from zhenxun.services.ai.config import get_llm_config
|
||||
|
||||
mcp_ttl = get_llm_config().agent_settings.mcp_cleanup_timeout
|
||||
return 31536000.0 if mcp_ttl <= 0 else float(mcp_ttl)
|
||||
except Exception:
|
||||
return 31536000.0 if self.toolkit.ttl <= 0 else float(self.toolkit.ttl)
|
||||
|
||||
async def get_definition(
|
||||
self, context: RunContext | None = None
|
||||
) -> ToolDefinition | None:
|
||||
if hasattr(self, "_dynamic_def") and self._dynamic_def is not None:
|
||||
return self._dynamic_def
|
||||
tool_def = ToolDefinition(
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
parameters=self.parameters,
|
||||
metadata=self.metadata or {},
|
||||
)
|
||||
if context and self.settings.capabilities:
|
||||
from zhenxun.services.ai.capabilities import (
|
||||
CombinedCapability,
|
||||
)
|
||||
|
||||
combined_cap = CombinedCapability(self.settings.capabilities)
|
||||
defs = await combined_cap.prepare_tools(context, [tool_def])
|
||||
if not defs:
|
||||
return None
|
||||
tool_def = defs[0]
|
||||
return tool_def
|
||||
|
||||
async def execute(self, context: RunContext | None = None, **kwargs) -> ToolResult:
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
await self.toolkit.lifespan_manager.touch(
|
||||
self.toolkit.server_name, self.toolkit.effective_ttl
|
||||
)
|
||||
|
||||
session = await self.toolkit.get_session(context)
|
||||
if not session:
|
||||
return ToolResult(output="MCP 连接错误").as_error()
|
||||
|
||||
try:
|
||||
result = await session.call_tool(self.original_tool_name, kwargs)
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
f"MCP 工具 '{self.name}' 执行失败 "
|
||||
f"(尝试 {attempt + 1}/{max_retries}): {e}。"
|
||||
"正在尝试自动重连自愈..."
|
||||
)
|
||||
if attempt < max_retries - 1:
|
||||
await self.toolkit.close()
|
||||
await asyncio.sleep(2**attempt)
|
||||
else:
|
||||
logger.error(
|
||||
f"MCP 工具 '{self.name}' "
|
||||
f"在重试 {max_retries} 次后仍然执行失败: {last_error}"
|
||||
)
|
||||
return ToolResult(output=f"MCP 错误: {last_error}").as_error()
|
||||
|
||||
if result.isError:
|
||||
return ToolResult(output=str(result.content)).as_error()
|
||||
|
||||
from zhenxun.services.ai.core.messages import ImagePart, TextPart
|
||||
|
||||
output_content = []
|
||||
img_count = 0
|
||||
|
||||
for item in result.content:
|
||||
item_type = getattr(item, "type", "text")
|
||||
|
||||
if item_type == "image":
|
||||
b64_data = getattr(item, "data", "")
|
||||
mime_type = getattr(item, "mimeType", "image/png")
|
||||
if b64_data:
|
||||
try:
|
||||
img_bytes = base64.b64decode(b64_data)
|
||||
output_content.append(
|
||||
ImagePart(raw=img_bytes, mime_type=mime_type)
|
||||
)
|
||||
img_count += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP 图片 Base64 解码失败: {e}")
|
||||
|
||||
output_content.append(TextPart(text="[图片解码失败]"))
|
||||
|
||||
elif item_type == "text":
|
||||
text = getattr(item, "text", str(item))
|
||||
output_content.append(TextPart(text=text))
|
||||
|
||||
md_images = re.findall(r"!\[.*?\]\((https?://[^\)]+)\)", text)
|
||||
for img_url in md_images:
|
||||
output_content.append(ImagePart(url=img_url))
|
||||
img_count += 1
|
||||
else:
|
||||
dumped = model_dump(item) if hasattr(item, "model_dump") else str(item)
|
||||
output_content.append(TextPart(text=str(dumped)))
|
||||
|
||||
tool_result = ToolResult(output=output_content)
|
||||
logger.info(
|
||||
f"获取到 {len(output_content)} 条返回数据,提取了 {img_count} 张图片"
|
||||
)
|
||||
|
||||
if img_count > 0 and context and context.run.event_bus:
|
||||
await context.run.event_bus.emit(UserCustomEvent(display=output_content))
|
||||
|
||||
return tool_result
|
||||
|
||||
|
||||
class MCPToolkit(BaseToolkit):
|
||||
"""模型上下文协议 (MCP) 的工具箱封装 (支持声明式挂载与动态隔离)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_name: str,
|
||||
prefix: str | None = None,
|
||||
transport: Literal[
|
||||
"stdio", "sse", "streamable-http", "sandbox_proxy"
|
||||
] = "stdio",
|
||||
command: str | None = None,
|
||||
args: list[str] | None = None,
|
||||
url: str | None = None,
|
||||
env: dict | None = None,
|
||||
cwd: str | None = None,
|
||||
install_command: str | None = None,
|
||||
isolation: Literal["shared", "per_session"] = "shared",
|
||||
timeout: int = 30,
|
||||
admin_level: int = 0,
|
||||
header_provider: Callable[[RunContext], dict[str, str]] | None = None,
|
||||
env_provider: Callable[[RunContext], dict[str, str]] | None = None,
|
||||
ttl: int = 600,
|
||||
sandbox_session_id: str | None = None,
|
||||
sandbox_blueprint: SandboxBlueprint | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
config=ToolkitConfig(prefix=prefix) if prefix is not None else None
|
||||
)
|
||||
self.server_name = server_name
|
||||
self.transport = transport
|
||||
self.command = command
|
||||
self.args = args or []
|
||||
self.url = url
|
||||
self.env = env or {}
|
||||
self.cwd = cwd
|
||||
self.install_command = install_command
|
||||
self.timeout = timeout
|
||||
self.admin_level = admin_level
|
||||
self.header_provider = header_provider
|
||||
self.env_provider = env_provider
|
||||
self.sandbox_session_id = sandbox_session_id
|
||||
self.sandbox_blueprint = sandbox_blueprint
|
||||
self.ttl = ttl
|
||||
|
||||
self._shared_session: ClientSession | None = None
|
||||
self._tools: list[BaseTool] = []
|
||||
self._is_initialized = False
|
||||
|
||||
self.lifespan_manager = LifespanManager()
|
||||
|
||||
self._shared_task: asyncio.Task | None = None
|
||||
self._stop_event: asyncio.Event = asyncio.Event()
|
||||
self._ready_event: asyncio.Event = asyncio.Event()
|
||||
self._init_exception: Exception | None = None
|
||||
|
||||
@property
|
||||
def effective_ttl(self) -> float:
|
||||
"""动态获取当前最新的 TTL 配置,支持无缝热重载"""
|
||||
try:
|
||||
from zhenxun.services.ai.config import get_llm_config
|
||||
|
||||
mcp_ttl = get_llm_config().agent_settings.mcp_cleanup_timeout
|
||||
return 31536000.0 if mcp_ttl <= 0 else float(mcp_ttl)
|
||||
except Exception:
|
||||
return 31536000.0 if self.ttl <= 0 else float(self.ttl)
|
||||
|
||||
async def _run_install_command(self, force: bool = False):
|
||||
"""执行环境预安装(热加载依赖)"""
|
||||
if not self.install_command or not self.cwd:
|
||||
return
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
marker_file = Path(self.cwd) / ".zx_installed"
|
||||
|
||||
heuristic_missing = False
|
||||
if (
|
||||
"npm" in self.install_command
|
||||
and not (Path(self.cwd) / "node_modules").exists()
|
||||
):
|
||||
heuristic_missing = True
|
||||
|
||||
if not force and not heuristic_missing and marker_file.exists():
|
||||
return
|
||||
|
||||
reason = "强制触发自愈重装" if force else "首次启动或检测到环境缺失"
|
||||
logger.info(
|
||||
f"🔧 [{self.server_name}] {reason},正在执行: `{self.install_command}` ..."
|
||||
)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
self.install_command,
|
||||
cwd=self.cwd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
err_msg = (
|
||||
stderr.decode("utf-8", errors="ignore")
|
||||
if stderr
|
||||
else stdout.decode("utf-8", errors="ignore")
|
||||
)
|
||||
logger.error(
|
||||
f"❌ [{self.server_name}] 环境安装失败!\n错误输出:\n{err_msg}"
|
||||
)
|
||||
raise RuntimeError(f"安装命令执行失败: {self.install_command}")
|
||||
else:
|
||||
marker_file.touch()
|
||||
logger.info(f"✅ [{self.server_name}] 环境装配成功!")
|
||||
except Exception as e:
|
||||
logger.error(f"执行预热安装异常: {e}")
|
||||
raise e
|
||||
|
||||
async def get_session(
|
||||
self, context: RunContext | None = None
|
||||
) -> ClientSession | None:
|
||||
await self.lifespan_manager.register(
|
||||
self.server_name,
|
||||
ttl=self.effective_ttl,
|
||||
cleanup_callback=self.release_resource,
|
||||
)
|
||||
if not self._is_initialized:
|
||||
await self.initialize(context)
|
||||
return self._shared_session
|
||||
|
||||
async def _spawn_session_task(self, dynamic_headers: dict, dynamic_env: dict):
|
||||
"""生成后台连接任务"""
|
||||
|
||||
max_attempts = 2 if (self.transport == "stdio" and self.install_command) else 1
|
||||
|
||||
try:
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
if self.transport == "stdio":
|
||||
await self._run_install_command(force=(attempt > 1))
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
read_stream: Any = None
|
||||
write_stream: Any = None
|
||||
if self.transport == "stdio":
|
||||
if not self.command:
|
||||
raise ValueError("stdio requires 'command'")
|
||||
|
||||
params = StdioServerParameters(
|
||||
command=self.command,
|
||||
args=self.args,
|
||||
env=dynamic_env,
|
||||
cwd=self.cwd,
|
||||
)
|
||||
transport_ctx = filtered_stdio_client(
|
||||
self.server_name, params
|
||||
)
|
||||
elif self.transport == "sse":
|
||||
if not self.url:
|
||||
raise ValueError("sse requires 'url'")
|
||||
transport_ctx = sse_client(
|
||||
url=self.url,
|
||||
headers=dynamic_headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
elif self.transport == "streamable-http":
|
||||
if not self.url:
|
||||
raise ValueError("streamable-http requires 'url'")
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
headers=dynamic_headers, timeout=self.timeout
|
||||
)
|
||||
await stack.enter_async_context(http_client)
|
||||
transport_ctx = streamable_http_client(
|
||||
url=self.url, http_client=http_client
|
||||
)
|
||||
elif self.transport == "sandbox_proxy":
|
||||
from zhenxun.services.ai.sandbox.manager import (
|
||||
sandbox_manager,
|
||||
)
|
||||
|
||||
target_session_id = (
|
||||
self.sandbox_session_id or "mcp_global_session"
|
||||
)
|
||||
bp = self.sandbox_blueprint or SandboxBlueprint()
|
||||
bp.enable_network = True
|
||||
|
||||
driver = await sandbox_manager.get_or_create_session(
|
||||
target_session_id,
|
||||
blueprint=bp,
|
||||
)
|
||||
|
||||
plugin_name = "universal_mcp"
|
||||
|
||||
await driver.mount_extension(plugin_name)
|
||||
mcp_plugin = cast(
|
||||
BaseMcpProxyExtension, driver.get_extension(plugin_name)
|
||||
)
|
||||
|
||||
if not self.command:
|
||||
raise ValueError("sandbox_proxy requires 'command'")
|
||||
|
||||
streams = await stack.enter_async_context(
|
||||
mcp_plugin.connect_mcp(
|
||||
self.command, self.args, dynamic_env
|
||||
)
|
||||
)
|
||||
read_stream, write_stream = streams[0], streams[1]
|
||||
transport_ctx = None
|
||||
else:
|
||||
raise ValueError(f"Unknown transport: {self.transport}")
|
||||
|
||||
if transport_ctx:
|
||||
transport = await stack.enter_async_context(transport_ctx)
|
||||
read_stream, write_stream = transport[0], transport[1]
|
||||
|
||||
client_session = await stack.enter_async_context(
|
||||
ClientSession(read_stream, write_stream)
|
||||
)
|
||||
await client_session.initialize()
|
||||
|
||||
self._shared_session = client_session
|
||||
|
||||
if not self._is_initialized:
|
||||
mcp_tools_res = await client_session.list_tools()
|
||||
tools_list = list(mcp_tools_res.tools)
|
||||
cursor = getattr(mcp_tools_res, "nextCursor", None)
|
||||
|
||||
while cursor:
|
||||
mcp_tools_res = await client_session.list_tools(
|
||||
cursor=cursor
|
||||
)
|
||||
tools_list.extend(mcp_tools_res.tools)
|
||||
cursor = getattr(mcp_tools_res, "nextCursor", None)
|
||||
|
||||
for t in tools_list:
|
||||
t_name = t.name.replace("-", "_")
|
||||
final_name = (
|
||||
f"{self.config.prefix}{t_name}"
|
||||
if self.config.prefix
|
||||
else t_name
|
||||
)
|
||||
self._tools.append(
|
||||
MCPRemoteTool(
|
||||
name=final_name,
|
||||
original_tool_name=t.name,
|
||||
description=t.description or "",
|
||||
parameters=t.inputSchema,
|
||||
toolkit=self,
|
||||
)
|
||||
)
|
||||
self._is_initialized = True
|
||||
|
||||
self._ready_event.set()
|
||||
logger.info(
|
||||
f"成功连接 MCP 服务器: {self.server_name}, "
|
||||
f"获取了 {len(self._tools)} 个工具"
|
||||
)
|
||||
|
||||
await self._stop_event.wait()
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
if isinstance(e, SandboxFatalError):
|
||||
raise e
|
||||
|
||||
if attempt < max_attempts:
|
||||
logger.warning(
|
||||
f"⚠️ [{self.server_name}] 进程启动或运行异常崩溃,"
|
||||
"疑似环境损坏。触发自愈机制 (准备重试)..."
|
||||
)
|
||||
self._init_exception = e
|
||||
self._shared_session = None
|
||||
if not self._is_initialized:
|
||||
self._is_initialized = False
|
||||
continue
|
||||
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
self._init_exception = e
|
||||
logger.error(
|
||||
f"MCP 服务器 '{self.server_name}' "
|
||||
f"初始化连接失败(模式: {self.transport})。"
|
||||
f"错误原因: {e}"
|
||||
)
|
||||
if "Connection closed" in str(e):
|
||||
logger.error(
|
||||
"提示:若是使用沙箱隧道,请进入 Docker Desktop 检查容器的日志。"
|
||||
"多半是因为 npx 命令执行出错"
|
||||
"(例如:镜像中缺少 Node 环境或国内网络无法连接 npm 等)。"
|
||||
)
|
||||
finally:
|
||||
if not self._is_initialized:
|
||||
self._is_initialized = False
|
||||
self._shared_session = None
|
||||
self._ready_event.set()
|
||||
|
||||
async def initialize(self, context: RunContext | None = None):
|
||||
if self._is_initialized:
|
||||
return
|
||||
|
||||
self._tools.clear()
|
||||
self._stop_event.clear()
|
||||
self._ready_event.clear()
|
||||
self._init_exception = None
|
||||
|
||||
dynamic_headers = {}
|
||||
dynamic_env = self.env.copy()
|
||||
|
||||
self._shared_task = asyncio.create_task(
|
||||
self._spawn_session_task(dynamic_headers, dynamic_env)
|
||||
)
|
||||
|
||||
await self._ready_event.wait()
|
||||
|
||||
if self._init_exception:
|
||||
raise self._init_exception
|
||||
|
||||
async def get_tools(self, context: RunContext | None = None) -> dict[str, BaseTool]:
|
||||
await self.lifespan_manager.register(
|
||||
self.server_name,
|
||||
ttl=self.effective_ttl,
|
||||
cleanup_callback=self.release_resource,
|
||||
)
|
||||
if not self._is_initialized:
|
||||
await self.initialize(context)
|
||||
|
||||
tools_dict = {}
|
||||
for t in self._tools:
|
||||
tools_dict[t.name] = t
|
||||
return tools_dict
|
||||
|
||||
async def release_resource(self, resource_id: str):
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
current_task = asyncio.current_task()
|
||||
|
||||
if (
|
||||
self.lifespan_manager._watchdog_task
|
||||
and self.lifespan_manager._watchdog_task is not current_task
|
||||
):
|
||||
await self.lifespan_manager.stop()
|
||||
|
||||
self._stop_event.set()
|
||||
self._is_initialized = False
|
||||
self._shared_session = None
|
||||
self._tools.clear()
|
||||
|
||||
if (
|
||||
self._shared_task
|
||||
and not self._shared_task.done()
|
||||
and self._shared_task is not current_task
|
||||
):
|
||||
try:
|
||||
await asyncio.wait_for(self._shared_task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"MCP 服务器 '{self.server_name}' 的任务执行超时,正在取消。"
|
||||
)
|
||||
self._shared_task.cancel()
|
||||
self._shared_task = None
|
||||
Reference in New Issue
Block a user