dark d3f5c82d98 feat: 补充 Agent 运行日志并增加 LLM 测试命令
- 新增统一日志工具,支持日志文件路径和级别配置
- 记录 CLI/chat、Agent、LLM、action、MCP、LangGraph、checkpoint 等关键流程
- 对日志中的 token、secret、api_key、Authorization 等敏感信息做脱敏
- chat 新增 llm test 命令,用于验证当前 LLM client 是否正常加载
- 同步 README、打包文档和 run.sh 帮助说明
- 补充日志脱敏和 llm test 相关测试
2026-06-04 10:51:59 +08:00

188 lines
6.7 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.

"""PAM_NODE MCP 工具的 action runner 封装。"""
from __future__ import annotations
import logging
from typing import Any, Protocol
from .logging_utils import json_for_log
from .models import ActionResult
from .output_parser import parse_mcp_result
logger = logging.getLogger(__name__)
class McpToolClient(Protocol):
"""MCP 工具客户端需要实现的最小同步接口。"""
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""调用指定 MCP tool并返回工具原始输出。"""
...
def list_tools(self) -> list[str]:
"""返回 MCP server 暴露的 tool 名称列表。"""
...
DEFAULT_NODE_MCP_TOOLS = {
"get-online-ips": "pam_get_online_ips",
"create-download-task": "pam_create_download_task",
"poll-download-progress": "pam_poll_download_progress",
"upgrade-ip": "pam_upgrade_ip",
"poll-upgrade-progress": "pam_poll_upgrade_progress",
"start-ip": "pam_start_ip",
"stop-ip": "pam_stop_ip",
"verify-ip": "pam_verify_ip",
"download-log": "pam_download_log",
"rollback-ip": "pam_rollback_ip",
}
class McpActionRunner:
"""把 Agent action 转换为 MCP tool 调用。"""
def __init__(
self,
client: McpToolClient | None = None,
tool_names: dict[str, str] | None = None,
) -> None:
"""保存 MCP client 和 action 到 tool name 的映射。"""
self.client = client
self.tool_names = tool_names or {}
self._discovered_tools: list[str] | None = None
logger.info(
"MCP action runner 初始化 client=%s explicit_tool_names=%s",
type(client).__name__ if client else "",
json_for_log(self.tool_names),
)
def run(
self,
action: str,
*,
params: dict[str, Any],
ip: str | None = None,
hash_code: str | None = None,
node_url: str | None = None,
stop_first: bool = False,
**_: Any,
) -> ActionResult:
"""执行一个 PAM_NODE action并归一化为 ActionResult。"""
if self.client is None:
raise RuntimeError("尚未配置 MCP client")
tool_name = self._resolve_tool_name(action)
arguments = self._build_arguments(
action,
params=params,
ip=ip,
hash_code=hash_code,
node_url=node_url,
stop_first=stop_first,
)
logger.info(
"MCP action 调用开始 action=%s tool=%s arguments=%s",
action,
tool_name,
json_for_log(arguments),
)
try:
payload = self.client.call_tool(tool_name, arguments)
except Exception as exc: # pragma: no cover - 防御性异常包装
logger.exception("MCP action 调用异常 action=%s tool=%s", action, tool_name)
return parse_mcp_result(action, {}, ok=False, tool_name=tool_name, error=str(exc))
logger.info("MCP action 原始返回 action=%s tool=%s payload=%s", action, tool_name, json_for_log(payload, max_text_len=1600))
result = parse_mcp_result(action, payload, ok=True, tool_name=tool_name)
logger.info(
"MCP action 解析完成 action=%s tool=%s ok=%s values=%s error=%s",
action,
tool_name,
result.ok,
json_for_log(result.values),
result.error_summary,
)
return result
def _resolve_tool_name(self, action: str) -> str:
"""根据显式映射、server tools 自动发现和默认约定解析 tool name。"""
explicit = self.tool_names.get(action)
if explicit:
logger.info("MCP tool 使用显式映射 action=%s tool=%s", action, explicit)
return explicit
discovered = self._list_discovered_tools()
if discovered:
candidates = _tool_name_candidates(action)
by_lower = {name.lower(): name for name in discovered}
for candidate in candidates:
matched = by_lower.get(candidate.lower())
if matched:
logger.info("MCP tool 自动匹配 action=%s tool=%s candidates=%s", action, matched, candidates)
return matched
available = ", ".join(discovered)
raise ValueError(f"MCP server 未发现 action 对应 tool: {action}; 已发现: {available}")
fallback = DEFAULT_NODE_MCP_TOOLS.get(action)
if fallback:
logger.info("MCP tool 使用默认约定 action=%s tool=%s", action, fallback)
return fallback
raise ValueError(f"action 未映射 MCP tool: {action}")
def _list_discovered_tools(self) -> list[str]:
"""读取并缓存 MCP server 暴露的 tool 名称。"""
if self._discovered_tools is not None:
return self._discovered_tools
if self.client is None or not hasattr(self.client, "list_tools"):
self._discovered_tools = []
return self._discovered_tools
try:
self._discovered_tools = list(self.client.list_tools())
except Exception:
logger.exception("MCP tool 自动发现失败,使用默认 tool name 约定")
self._discovered_tools = []
logger.info("MCP tool 自动发现完成 tools=%s", self._discovered_tools)
return self._discovered_tools
def _build_arguments(
self,
action: str,
*,
params: dict[str, Any],
ip: str | None,
hash_code: str | None,
node_url: str | None,
stop_first: bool,
) -> dict[str, Any]:
"""把 Agent 参数转换为 MCP tool 所需的入参。"""
arguments = {
"homeBaseUrl": params.get("HOME_BASE_URL"),
"airportCode": params.get("AIRPORT_CODE"),
"applicationName": params.get("APP_NAME"),
"moduleName": params.get("MODULE_NAME"),
"versionNumber": params.get("VERSION_NUMBER"),
"actionType": params.get("ACTION_TYPE"),
"timeOut": params.get("TIMEOUT"),
"logName": params.get("LOG_NAME"),
}
if ip:
arguments["targetIp"] = ip
if hash_code:
arguments["hashCode"] = hash_code
if node_url:
arguments["nodeUrl"] = node_url
if action == "rollback-ip":
arguments["stopFirst"] = stop_first
return {key: value for key, value in arguments.items() if value not in (None, "")}
def _tool_name_candidates(action: str) -> list[str]:
"""生成 action 自动匹配 MCP tool 的候选名称。"""
snake = action.replace("-", "_")
return [
action,
snake,
f"pam_{snake}",
f"pam_node_{snake}",
f"pam.node.{snake}",
f"pam-node.{action}",
]