mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 14:31:58 +00:00
62aac187d8
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 处变异验证、全部抓到。
218 lines
9.2 KiB
Python
218 lines
9.2 KiB
Python
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
|