- vendor_hermes.sh: 补全遗漏的单文件模块和目录 - pyproject.toml: 添加 python-dotenv 隐性依赖 - 全量 import 测试通过(216 .py, 180K 行) - firecrawl/fal_client 为可选工具,缺失时优雅跳过
325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""
|
||
CLI Tunnel 工具 — 动态注册 Mind CLI 本地工具到 Hermes 编排器。
|
||
|
||
设计遵循 SPEC 铁律 B(能力报告义务):
|
||
CLI 连接时上报能力 → Cloud 审批 → 动态注册到 ToolRegistry
|
||
CLI 断开 → 从 ToolRegistry 注销
|
||
|
||
注册模式参照 MCP 动态发现(mcp_tool._register_server_tools),
|
||
用户隔离参照飞书连接器(feishu_tool.handler(args.user_id))。
|
||
|
||
与内置工具通过 `cli_` 前缀区分:
|
||
cli_terminal = 在用户本地电脑执行命令
|
||
terminal = 在云端服务器执行命令
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from typing import Any
|
||
|
||
from tools.registry import registry, tool_error, tool_result
|
||
|
||
logger = logging.getLogger("tools.cli_tunnel")
|
||
|
||
# ── 模块级 bridge 引用(由 mindos_sse.py 注入) ────────────
|
||
_bridge = None
|
||
|
||
|
||
def set_bridge(bridge) -> None:
|
||
"""注入 MindCLIBridge 实例(进程生命周期内调用一次)。"""
|
||
global _bridge
|
||
_bridge = bridge
|
||
|
||
|
||
# ── 工具 Schema 模板 ─────────────────────────────────────
|
||
# 每个 CLI 内置工具对应一个 schema 模板,用于注册到 LLM function calling。
|
||
# 只有 capability_report 中出现的工具才会被注册。
|
||
|
||
_TOOL_SCHEMAS: dict[str, dict] = {
|
||
"terminal": {
|
||
"name": "cli_terminal",
|
||
"description": (
|
||
"在用户的本地电脑上执行终端命令。"
|
||
"用于需要访问用户本地文件系统、开发环境或系统工具的场景。"
|
||
"注意:这是在用户个人电脑上执行,不是在服务器上。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"command": {
|
||
"type": "string",
|
||
"description": "要执行的 shell 命令",
|
||
},
|
||
"cwd": {
|
||
"type": "string",
|
||
"description": "工作目录(可选,默认用户 home)",
|
||
},
|
||
"timeout": {
|
||
"type": "integer",
|
||
"description": "超时秒数(可选,默认 30)",
|
||
},
|
||
},
|
||
"required": ["command"],
|
||
},
|
||
},
|
||
"file_read": {
|
||
"name": "cli_file_read",
|
||
"description": (
|
||
"读取用户本地电脑上的文件内容。"
|
||
"用于查看用户电脑上的配置文件、代码、文档等。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"path": {
|
||
"type": "string",
|
||
"description": "文件绝对路径",
|
||
},
|
||
},
|
||
"required": ["path"],
|
||
},
|
||
},
|
||
"file_write": {
|
||
"name": "cli_file_write",
|
||
"description": (
|
||
"写入文件到用户本地电脑。"
|
||
"用于创建或更新用户电脑上的文件。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"path": {
|
||
"type": "string",
|
||
"description": "文件绝对路径",
|
||
},
|
||
"content": {
|
||
"type": "string",
|
||
"description": "文件内容",
|
||
},
|
||
},
|
||
"required": ["path", "content"],
|
||
},
|
||
},
|
||
"file_ops": {
|
||
"name": "cli_file_ops",
|
||
"description": (
|
||
"在用户本地电脑上执行文件操作(复制/移动/删除/列表)。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"operation": {
|
||
"type": "string",
|
||
"description": "操作类型",
|
||
"enum": ["copy", "move", "delete", "list"],
|
||
},
|
||
"source": {
|
||
"type": "string",
|
||
"description": "源路径",
|
||
},
|
||
"destination": {
|
||
"type": "string",
|
||
"description": "目标路径(copy/move 时必填)",
|
||
},
|
||
},
|
||
"required": ["operation", "source"],
|
||
},
|
||
},
|
||
"grep": {
|
||
"name": "cli_grep",
|
||
"description": (
|
||
"在用户本地电脑上搜索文件内容。"
|
||
"用于在用户项目中查找代码、配置等。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"pattern": {
|
||
"type": "string",
|
||
"description": "搜索模式",
|
||
},
|
||
"path": {
|
||
"type": "string",
|
||
"description": "搜索路径(可选,默认当前目录)",
|
||
},
|
||
},
|
||
"required": ["pattern"],
|
||
},
|
||
},
|
||
"code_execution": {
|
||
"name": "cli_code_execution",
|
||
"description": (
|
||
"在用户本地电脑上执行代码片段(Python)。"
|
||
"用于需要在用户本地环境中运行脚本的场景。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"code": {
|
||
"type": "string",
|
||
"description": "要执行的代码",
|
||
},
|
||
"language": {
|
||
"type": "string",
|
||
"description": "编程语言(当前仅支持 python)",
|
||
"enum": ["python"],
|
||
},
|
||
},
|
||
"required": ["code"],
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
# ── 动态注册 / 注销 ──────────────────────────────────────
|
||
|
||
def register_cli_tools(
|
||
user_id: str,
|
||
capabilities: dict,
|
||
) -> list[str]:
|
||
"""
|
||
CLI 连接时调用——根据能力报告动态注册工具到 Hermes ToolRegistry。
|
||
|
||
Args:
|
||
user_id: 用户 ID
|
||
capabilities: CLI 上报的 capability_report
|
||
|
||
Returns:
|
||
已注册的工具名列表(带 cli_ 前缀)
|
||
"""
|
||
from toolsets import create_custom_toolset, TOOLSETS
|
||
|
||
if not _bridge:
|
||
logger.warning("[CliTunnel] bridge 未初始化,跳过工具注册")
|
||
return []
|
||
|
||
reported_tools = {t["name"] for t in capabilities.get("tools", [])}
|
||
registered: list[str] = []
|
||
|
||
for local_name, schema in _TOOL_SCHEMAS.items():
|
||
if local_name not in reported_tools:
|
||
continue
|
||
|
||
prefixed_name = schema["name"] # e.g. "cli_terminal"
|
||
|
||
# 避免与非 CLI 内置工具冲突
|
||
existing_toolset = registry.get_toolset_for_tool(prefixed_name)
|
||
if existing_toolset and existing_toolset != "connectors":
|
||
logger.warning(
|
||
"[CliTunnel] 工具 '%s' 与 toolset '%s' 冲突,跳过",
|
||
prefixed_name, existing_toolset,
|
||
)
|
||
continue
|
||
|
||
registry.register(
|
||
name=prefixed_name,
|
||
toolset="connectors",
|
||
schema=schema,
|
||
handler=_make_cli_handler(local_name, user_id),
|
||
check_fn=_make_check_fn(user_id),
|
||
is_async=False,
|
||
description=schema["description"],
|
||
emoji="💻",
|
||
)
|
||
registered.append(prefixed_name)
|
||
|
||
# 注入到 hermes-* umbrella toolsets 以确保通过 enabled_toolsets 过滤
|
||
if registered:
|
||
for ts_name, ts in TOOLSETS.items():
|
||
if ts_name.startswith("hermes-"):
|
||
for tool_name in registered:
|
||
if tool_name not in ts["tools"]:
|
||
ts["tools"].append(tool_name)
|
||
|
||
logger.info(
|
||
"[CliTunnel] 为用户 %s 注册 %d 个本地工具: %s",
|
||
user_id, len(registered), ", ".join(registered),
|
||
)
|
||
return registered
|
||
|
||
|
||
def deregister_cli_tools(tools: list[str]) -> None:
|
||
"""
|
||
CLI 断开时调用——从 ToolRegistry 注销工具。
|
||
|
||
Args:
|
||
tools: 之前 register_cli_tools 返回的工具名列表
|
||
"""
|
||
from toolsets import TOOLSETS
|
||
|
||
for name in tools:
|
||
registry.deregister(name)
|
||
# 从 hermes-* umbrella toolsets 中移除
|
||
for ts_name, ts in TOOLSETS.items():
|
||
if ts_name.startswith("hermes-"):
|
||
try:
|
||
ts["tools"].remove(name)
|
||
except ValueError:
|
||
pass
|
||
|
||
if tools:
|
||
logger.info("[CliTunnel] 注销 %d 个本地工具: %s", len(tools), ", ".join(tools))
|
||
|
||
|
||
# ── Handler 工厂 ─────────────────────────────────────────
|
||
|
||
def _make_cli_handler(local_tool_name: str, user_id: str):
|
||
"""
|
||
返回闭包 handler,走 Tunnel 派发工具调用。
|
||
|
||
签名:handler(args, **kwargs) -> str(符合 registry.dispatch 要求)
|
||
"""
|
||
def _handler(args: dict, **kwargs) -> str:
|
||
if not _bridge:
|
||
return tool_error("CLI Bridge 未初始化")
|
||
|
||
if not _bridge.is_connected(user_id):
|
||
return tool_error(
|
||
f"本地 CLI 未连接。请确保 Mind CLI 正在运行并已连接隧道。"
|
||
)
|
||
|
||
# 同步调用异步 dispatch(复用 model_tools._run_async 模式)
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
loop = None
|
||
|
||
coro = _bridge.dispatch_tool_call(user_id, local_tool_name, args)
|
||
|
||
try:
|
||
if loop and loop.is_running():
|
||
# 在 async 上下文中(SSE gateway)→ 开线程
|
||
import concurrent.futures
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||
future = pool.submit(asyncio.run, coro)
|
||
result = future.result(timeout=130)
|
||
else:
|
||
result = asyncio.run(coro)
|
||
except Exception as e:
|
||
logger.error("[CliTunnel] 工具 %s 调用失败: %s", local_tool_name, e)
|
||
return tool_error(f"本地工具调用失败: {e}")
|
||
|
||
# result 是 dict(从 CLI 返回的 JSON-RPC result)
|
||
if isinstance(result, dict) and "error" in result:
|
||
return tool_error(result["error"])
|
||
|
||
return json.dumps(
|
||
{"ok": True, "tool": local_tool_name, "result": result},
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
return _handler
|
||
|
||
|
||
def _make_check_fn(user_id: str):
|
||
"""
|
||
返回 check_fn 闭包——registry 每次 get_definitions 时调用。
|
||
|
||
只有当 bridge 存在且该用户 CLI 在线时才返回 True,
|
||
离线时工具自动从 LLM 视野消失。
|
||
"""
|
||
def _check() -> bool:
|
||
return bool(_bridge and _bridge.is_connected(user_id))
|
||
return _check
|