mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:12:00 +00:00
964eeaa829
在画布上拖拽编排 agent 工作流,并实时看着它执行。 后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点 (模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、 人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、 恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。 前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时 节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。 关键实现: - Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费 - 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被 只读容器拒绝),断网、非 root、内存/CPU/超时限额 - 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用 - 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址 - Mock provider 让无 API Key 也能跑通完整链路 7 个内置模板全部冒烟通过,含真实模型端到端验证。
134 lines
4.9 KiB
Python
134 lines
4.9 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
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 {}),
|
|
}
|
|
|
|
|
|
class McpManager:
|
|
"""管理外部 MCP server 的连接与工具缓存。
|
|
|
|
工具在图里用 `mcp:<server>/<tool>` 引用。连接是懒建立的,
|
|
并且按 server 缓存,避免每个节点都去重新拉起一次 stdio 子进程。
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._tools: dict[str, list[BaseTool]] = {} # server name -> tools
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def _load_servers(self) -> list[McpServer]:
|
|
async with SessionLocal() as session:
|
|
rows = await session.execute(select(McpServer).where(McpServer.enabled.is_(True)))
|
|
return list(rows.scalars())
|
|
|
|
async def _connect(self, rows: list[McpServer]) -> dict[str, list[BaseTool]]:
|
|
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
|
|
out: dict[str, list[BaseTool]] = {}
|
|
for row in rows:
|
|
try:
|
|
client = MultiServerMCPClient({row.name: _server_config(row)})
|
|
tools = await asyncio.wait_for(client.get_tools(), timeout=30)
|
|
out[row.name] = list(tools)
|
|
await self._mark(row.id, "ok", None, [t.name for t in tools])
|
|
except Exception as e: # noqa: BLE001 - 一个 server 挂了不影响其他的
|
|
logger.warning("MCP server %s 连接失败: %s", row.name, e)
|
|
out[row.name] = []
|
|
await self._mark(row.id, "error", f"{type(e).__name__}: {e}", [])
|
|
return out
|
|
|
|
async def _mark(
|
|
self, server_id: str, status: str, error: str | None, tool_names: list[str]
|
|
) -> None:
|
|
async with SessionLocal() as session:
|
|
row = await session.get(McpServer, server_id)
|
|
if row:
|
|
row.status = status
|
|
row.last_error = error
|
|
row.tools_cache = tool_names
|
|
await session.commit()
|
|
|
|
async def refresh(self) -> dict[str, list[str]]:
|
|
async with self._lock:
|
|
rows = await self._load_servers()
|
|
self._tools = await self._connect(rows)
|
|
return {name: [t.name for t in tools] for name, tools in self._tools.items()}
|
|
|
|
async def ensure_loaded(self) -> None:
|
|
if not self._tools:
|
|
await self.refresh()
|
|
|
|
async def list_tools(self) -> list[dict[str, Any]]:
|
|
await self.ensure_loaded()
|
|
out: list[dict[str, Any]] = []
|
|
for server, tools in self._tools.items():
|
|
for tool in tools:
|
|
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 全部工具。"""
|
|
await self.ensure_loaded()
|
|
wanted: list[BaseTool] = []
|
|
for ref in refs:
|
|
body = ref[4:] if ref.startswith("mcp:") else ref
|
|
server, _, tool_name = body.partition("/")
|
|
tools = self._tools.get(server, [])
|
|
if tool_name in ("", "*"):
|
|
wanted.extend(tools)
|
|
continue
|
|
for tool in tools:
|
|
if tool.name == tool_name:
|
|
wanted.append(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
|
|
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
|
|
|
|
mcp_manager = McpManager()
|