Files
AgentLab/backend/app/tools/mcp_manager.py
T
ThearchyHelios 62aac187d8 工具审批三档、智能体护栏取代固定 12 步、检查脚本分道并行
MCP / 自定义工具的审批三档(tools/trust.py)
- 平台看不出这两类工具做什么,以前审批关卡一律当它们安全、照跑不误。现在默认每次调用都等审批;
  工具页可以逐个改成「始终允许 · 门控把关」或「始终允许」,审批卡上的「始终允许」等于前者
- 门控把关:每次调用先由门控模型审一遍工具说明、参数和节点任务;判可疑、出错、超时、答不成格式,
  一律交还给人,不会变成放行。判定包进 checkpoint,审批恢复重放时不再问一遍
- agent 节点、工具节点、协作团队三处关卡都接上;团队成员停不下来等人,门控没放行的调用不执行
- 每次运行在发起时快照档位(runs.tool_trust),恢复和接着跑沿用同一份;正式运行不看档位,一律等审批;
  升级前发起的运行照旧不问——它们可能正停在某个审批上,多问一次答复就会对错号
- 接口:GET /api/tools 带 trust / trust_key,PUT /api/tools/trust;设置新增门控模型;新事件 tool.gated
- 界面:工具页三选一、审批卡「始终允许」、设置页门控模型、运行记录解码 tool.gated;
  顺手修了 MCP 工具详情 404、工具审批被驳回却显示成放行

智能体护栏(engine/guards.py),取代固定的「最多 12 步」
- 步数只留兜底:默认 100,设置里可改;以前画布自动写进节点的 12 按「没填」处理
- 同一个工具、同样的参数第二次不再执行,把上次的结果交还给它;连续 3 步拿不到新信息就收尾
- 每个节点的令牌预算(默认 200 万)和金额预算(默认不限),都可以在设置里改成不限
- 上下文用到窗口的 60% 先压缩早期的工具结果,到 85% 就收尾;临近任何一个上限先提醒模型一次
- 下游结构化校验失败时写明是上游哪个 agent 提前收了尾;复核信号带上收尾原因,
  问数据页只在真是步数用满时才给「放宽步数重跑」
- 上限在发起时快照(runs.agent_limits);升级前发起的运行一字不改走旧逻辑

检查脚本
- check-all 默认 4 道并行:量时间最多的两项互不重叠;并行时没过的最后单独重跑一遍,重跑才过的
  单列成「偶发」。16 项全量从约 21 分钟降到约 7 分钟(加上一次重跑约 10 分钟);--lanes 1 回到串行

顺带修的
- 设置接口里的 limits 改为只读:库里存着旧版设置页写进去的一份(max_agent_steps: 25),从不生效,
  却盖住了真实上限,问数据页照着错的数许诺
- test_kb 里嵌入模型那条测试不再真连 api.openai.com(代理慢时一条要等近 4 分钟)
- check-stream 读滚动容器前先等它挂上,并行跑时不再偶发

验证(沙箱):tsc 干净,生产构建通过,pytest 1655 passed(两遍),check-all 16 项检查、3562 项全过;
新增后端测试 53 条,关键逻辑做了 22 处变异验证、全部抓到。
2026-09-28 11:47:45 +08:00

218 lines
9.2 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.
from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any
from langchain_core.tools import BaseTool
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import McpServer
logger = logging.getLogger(__name__)
def _server_config(row: McpServer) -> dict[str, Any]:
if row.transport == "http":
return {"transport": "streamable_http", "url": row.url or ""}
return {
"transport": "stdio",
"command": row.command or "",
"args": list(row.args or []),
"env": dict(row.env or {}),
}
def _config_key(row: McpServer) -> str:
"""一个服务的连接配置,缓存据此判断手上的工具是不是按现在这份配置建的。"""
return json.dumps(_server_config(row), sort_keys=True, ensure_ascii=False)
class McpManager:
"""管理外部 MCP server 的连接与工具缓存。
工具在图里用 `mcp:<server>/<tool>` 引用。连接是懒建立的,
并且按 server 缓存,避免每个节点都去重新拉起一次 stdio 子进程。
缓存按服务逐个补、逐个作废。以前只在「一个都没加载过」时整批建一次:改了
启动命令,运行照样用旧命令建出来的工具;删掉的服务照样调得到;缓存建好之后
新加的服务,工具列表里一直没有它,除非有人去点刷新。现在每次取工具都先对一遍
库里启用着的服务:缺的连上,配置变了的重连,删了、停用了的不再交出去。
"""
def __init__(self) -> None:
self._tools: dict[str, list[BaseTool]] = {} # server name -> tools
#: 每个服务的工具是按哪份连接配置建的(_config_key)
self._built_from: dict[str, str] = {}
self._lock = asyncio.Lock()
def invalidate(self, *names: str) -> None:
"""服务改了、删了:丢掉按旧配置建的工具,下次用到时再连。"""
for name in names:
self._tools.pop(name, None)
self._built_from.pop(name, None)
def _fresh(self, row: McpServer) -> bool:
return row.name in self._tools and self._built_from.get(row.name) == _config_key(row)
async def _load_servers(self, names: list[str] | None = None) -> list[McpServer]:
async with SessionLocal() as session:
query = select(McpServer).where(McpServer.enabled.is_(True))
if names is not None:
query = query.where(McpServer.name.in_(names))
rows = await session.execute(query)
return list(rows.scalars())
async def _connect(self, rows: list[McpServer]) -> None:
from langchain_mcp_adapters.client import MultiServerMCPClient
for row in rows:
key = _config_key(row)
started = time.perf_counter()
try:
client = MultiServerMCPClient({row.name: _server_config(row)})
tools = list(await asyncio.wait_for(client.get_tools(), timeout=30))
status, error = "ok", None
except Exception as e: # noqa: BLE001 - 一个 server 挂了不影响其他的
logger.warning("MCP server %s 连接失败: %s", row.name, e)
tools, status, error = [], "error", _reason(e, row.command)
# 连不上也缓存成空:不然每次取工具都要再等一轮 30 秒超时。改配置或刷新时重连
self._tools[row.name] = tools
self._built_from[row.name] = key
await self._mark(row.id, key, status, error, [t.name for t in tools], started)
async def _mark(
self, server_id: str, key: str, status: str, error: str | None, tool_names: list[str],
started: float,
) -> None:
from app.api.health import record
async with SessionLocal() as session:
row = await session.get(McpServer, server_id)
# 连到一半服务被改了:这次连的是旧配置,它的结论不能盖掉改完之后的「未检查」
if row and _config_key(row) == key:
row.status = status
row.last_error = error
row.tools_cache = tool_names
# 加载工具本身就是一次连接:记下来,工具页不用再单独测一次才知道
row.last_check = record(
status == "ok", int((time.perf_counter() - started) * 1000), error)
await session.commit()
async def _ensure(self, rows: list[McpServer]) -> None:
"""rows 里还没连过、或者是按旧配置连的,连一次补上。"""
if all(self._fresh(r) for r in rows):
return
async with self._lock:
# 等锁的时候别人可能已经连好了
stale = [r for r in rows if not self._fresh(r)]
if stale:
await self._connect(stale)
async def refresh(self) -> dict[str, list[str]]:
"""全部重连一遍(服务端的工具变了,配置没变时用)。"""
async with self._lock:
rows = await self._load_servers()
self.invalidate(*(set(self._tools) - {r.name for r in rows}))
await self._connect(rows)
return {r.name: [t.name for t in self._tools.get(r.name, [])] for r in rows}
async def ensure_loaded(self) -> None:
await self._ensure(await self._load_servers())
async def list_tools(self) -> list[dict[str, Any]]:
rows = await self._load_servers()
await self._ensure(rows)
out: list[dict[str, Any]] = []
for row in rows:
server = row.name
for tool in self._tools.get(server, []):
out.append(
{
"id": f"mcp:{server}/{tool.name}",
"name": tool.name,
"server": server,
"description": tool.description or "",
"category": f"MCP · {server}",
"schema": tool.args_schema.model_json_schema()
if getattr(tool, "args_schema", None)
else {},
}
)
return out
async def get_tools(self, refs: list[str]) -> list[BaseTool]:
"""按 `mcp:<server>/<tool>` 取工具;`mcp:<server>/*` 表示该 server 全部工具。
只交出库里还在、并且启用着的服务的工具:删了、停用了的,缓存里就算还有也不给。
"""
parsed = []
for ref in refs:
body = ref[4:] if ref.startswith("mcp:") else ref
server, _, tool_name = body.partition("/")
parsed.append((server, tool_name))
rows = await self._load_servers(sorted({server for server, _ in parsed}))
await self._ensure(rows)
live = {r.name for r in rows}
wanted: list[BaseTool] = []
for server, tool_name in parsed:
tools = self._tools.get(server, []) if server in live else []
if tool_name in ("", "*"):
wanted.extend(_keyed(server, t) for t in tools)
continue
for tool in tools:
if tool.name == tool_name:
wanted.append(_keyed(server, tool))
break
return wanted
async def probe(self, row: McpServer) -> dict[str, Any]:
"""连一次看看,用于设置页的"测试连接"。"""
from langchain_mcp_adapters.client import MultiServerMCPClient
try:
client = MultiServerMCPClient({row.name: _server_config(row)})
tools = await asyncio.wait_for(client.get_tools(), timeout=30)
return {
"ok": True,
"tools": [
{"name": t.name, "description": t.description or ""} for t in tools
],
}
except Exception as e: # noqa: BLE001
from app.core.errors import raw
return {"ok": False, "error": _reason(e, row.command), "detail": raw(e)}
def _reason(e: BaseException, command: str | None = None) -> str:
"""连不上 MCP 服务的原因,写成工具页上看得懂的话。
最常见的两种:命令不存在(没装 npx / uvx)和超时(进程起来了但不讲 MCP)。
"""
from app.core.errors import explain
if isinstance(e, FileNotFoundError):
# stdio 客户端抛的这个异常常常不带文件名,只剩「找不到命令 」半句:补上配置的命令
missing = e.filename or command or ""
return f"启动不了:找不到命令 {missing}。确认它已安装、并且在后端的 PATH 里"
if isinstance(e, asyncio.TimeoutError):
return "连上了但 30 秒内没有应答:确认这个命令启动的是 MCP 服务,参数有没有写错"
reason, hint = explain(e)
return f"{reason}。{hint}" if hint else reason
mcp_manager = McpManager()
def _keyed(server: str, tool: BaseTool) -> BaseTool:
"""标上信任三档用的 key(`mcp:<server>/<tool>`,和工具列表里的 id 一致)。
缓存里的工具对象是共用的,key 只由 server 和工具名决定,重复标同一个值不会串。
"""
tool.metadata = {**(tool.metadata or {}), "trust_key": f"mcp:{server}/{tool.name}"}
return tool