dark 1e74ae3cd6 feat: 增加 PAM 部署 Agent 交互式 CLI 与真实 LLM 配置
- 新增 OpenAI-compatible LLM client,支持 base_url、api_key、model 配置
- 固化意图识别、参数抽取、部署计划生成的结构化 JSON 提示词
- 增加 MCP client 配置读取和真实 session 接入说明
- 实现 checkpoint 自动保存、resume 断点续跑和已完成步骤跳过
- 实现人工确认流程,支持失败 IP 回滚 approve/reject
- 新增 chat 常驻式 CLI 对话框,支持自然语言分析、参数设置、执行确认、状态查看、回滚确认和续跑
- 同步 README,补充 LLM、MCP、checkpoint、confirm/resume、chat 使用方式
- 增加相关单元测试,覆盖 LLM client、MCP 配置、确认/续跑和交互式 CLI
2026-06-01 10:26:40 +08:00

93 lines
3.0 KiB
Python

"""MCP client adapters.
The Agent only needs a synchronous `call_tool(name, arguments)` surface. This
module adapts simple callables or SDK-like sessions to that surface without
forcing the rest of the codebase to import a concrete MCP SDK.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class McpClientConfig:
"""Configuration needed after a real MCP session has been created."""
server_name: str = "pam-node"
tool_names: dict[str, str] = field(default_factory=dict)
@classmethod
def from_mapping(cls, payload: dict[str, Any]) -> "McpClientConfig":
tool_names = payload.get("tool_names") or payload.get("tools") or {}
if not isinstance(tool_names, dict):
raise ValueError("MCP tool_names must be an object")
return cls(
server_name=str(payload.get("server_name", "pam-node")),
tool_names={str(key): str(value) for key, value in tool_names.items()},
)
def load_mcp_client_config(path: str | Path) -> McpClientConfig:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("MCP client config must be a JSON object")
return McpClientConfig.from_mapping(payload)
class FunctionMcpToolClient:
"""Wrap a plain Python callable as an MCP tool client."""
def __init__(self, caller: Callable[[str, dict[str, Any]], Any]) -> None:
self.caller = caller
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
return self.caller(tool_name, arguments)
class SessionMcpToolClient:
"""Adapt SDK-like sessions exposing `call_tool`.
The adapter accepts common result shapes:
- raw dict/list/string
- object with `structuredContent`
- object with `content`, where text content may contain JSON
"""
def __init__(self, session: Any) -> None:
if not hasattr(session, "call_tool"):
raise TypeError("MCP session must expose call_tool")
self.session = session
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
result = self.session.call_tool(tool_name, arguments)
return normalize_mcp_sdk_result(result)
def normalize_mcp_sdk_result(result: Any) -> Any:
if hasattr(result, "structuredContent"):
structured = getattr(result, "structuredContent")
if structured is not None:
return structured
if hasattr(result, "content"):
content = getattr(result, "content")
text_parts: list[str] = []
for item in content or []:
text = getattr(item, "text", None)
if text is not None:
text_parts.append(text)
if text_parts:
joined = "\n".join(text_parts)
try:
return json.loads(joined)
except json.JSONDecodeError:
return joined
return result