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
This commit is contained in:
dark 2026-06-01 10:26:40 +08:00
parent 14e297a488
commit 1e74ae3cd6
16 changed files with 1297 additions and 38 deletions

129
README.md
View File

@ -26,9 +26,10 @@ pam_deploy_graph/
config_writer.py # 生成脚本 action 所需 config 文件 config_writer.py # 生成脚本 action 所需 config 文件
checkpoint_store.py # 业务 checkpoint JSON 读写 checkpoint_store.py # 业务 checkpoint JSON 读写
params_loader.py # 读取 JSON 或 config.txt 风格参数文件 params_loader.py # 读取 JSON 或 config.txt 风格参数文件
llm/ # LLM structured output 接口、规则 fallback 和 guardrails llm/ # LLM structured output 接口、真实 HTTP client、提示词、规则 fallback 和 guardrails
graph.py # LangGraph StateGraph 集成入口 graph.py # LangGraph StateGraph 集成入口
mcp_client.py # MCP session/callable adapter mcp_client.py # MCP session/callable adapter 与 client 配置读取
interactive.py # 常驻式 CLI 对话框,会话命令、确认和续跑
cli.py # CLI 入口 cli.py # CLI 入口
tests/ tests/
@ -37,6 +38,7 @@ tests/
test_params_loader.py test_params_loader.py
test_script_runner.py test_script_runner.py
test_skill_policy.py test_skill_policy.py
test_interactive_cli.py
``` ```
## 当前进度 ## 当前进度
@ -53,24 +55,111 @@ tests/
- 实现 fake 全局流程和完整部署流程,便于不触碰真实环境地验证 Agent 路由。 - 实现 fake 全局流程和完整部署流程,便于不触碰真实环境地验证 Agent 路由。
- 实现逐 IP 处理骨架:升级、轮询、启动、校验、日志下载。 - 实现逐 IP 处理骨架:升级、轮询、启动、校验、日志下载。
- 实现单 IP 失败后的待回滚确认状态,不自动执行回滚。 - 实现单 IP 失败后的待回滚确认状态,不自动执行回滚。
- 实现人工确认入口:`confirm --decision approve|reject` 只处理待确认回滚。
- 实现 checkpoint 自动保存和 `resume` 续跑:全局步骤、成功 IP、单 IP 已完成 action 会跳过。
- 实现 LLM structured output 骨架:意图识别、参数抽取、部署计划生成。 - 实现 LLM structured output 骨架:意图识别、参数抽取、部署计划生成。
- 实现 OpenAI-compatible 真实 LLM client支持 `base_url` / `api_key` / `model` 配置。
- 固化真实 LLM 提示词:意图识别、参数抽取、部署计划生成均要求 JSON structured output。
- 增加规则 fallback `RuleBasedLlmClient`,用于本地开发和测试。 - 增加规则 fallback `RuleBasedLlmClient`,用于本地开发和测试。
- 增加 LLM 输出 guardrails禁止计划中出现可执行脚本命令和非法 action。 - 增加 LLM 输出 guardrails禁止计划中出现可执行脚本命令和非法 action。
- 引入 `langgraph` 依赖,并提供 `build_langgraph()` 图工厂。 - 引入 `langgraph` 依赖,并提供 `build_langgraph()` 图工厂。
- 引入 MCP client adapter可包装 SDK session 或普通 callable。 - 引入 MCP client adapter可包装 SDK session 或普通 callable,并提供 JSON client 配置读取
- 本地已安装 `langgraph``mcp`,并完成 LangGraph fake 全局流程 smoke。 - 本地已安装 `langgraph``mcp`,并完成 LangGraph fake 全局流程 smoke。
- CLI `analyze` 输出已做敏感字段脱敏。 - CLI `analyze` 输出已做敏感字段脱敏。
- 添加基础测试,当前本地结果为 `22 passed, 1 skipped` - 增加 `chat` 常驻式 CLI 对话框,支持自然语言分析、参数设置、执行确认、回滚确认、状态查看和续跑。
- 添加基础测试,当前本地结果为 `31 passed, 1 skipped`
未完成: 未完成:
- 尚未接入真实 MCP client。 - 尚未接入真实 MCP session当前已把 client adapter、tool 映射和配置格式准备好。
- 尚未接入真实 LLM 服务,目前使用规则 fallback。
- 尚未实现人工确认 interrupt、断点续跑完整图流程和单 IP 子流程。
- 尚未执行真实脚本 action 或真实 PAM_NODE MCP 调用。 - 尚未执行真实脚本 action 或真实 PAM_NODE MCP 调用。
## LLM 配置
默认不配置 LLM 时,`analyze` 使用本地规则 fallback。配置真实 LLM 后,会走 OpenAI-compatible `/chat/completions`
```powershell
$env:PAM_LLM_BASE_URL="https://your-llm.example.com/v1"
$env:PAM_LLM_API_KEY="your-api-key"
$env:PAM_LLM_MODEL="your-model-name"
python -m pam_deploy_graph.cli analyze --config doc_scripts/config.txt.example --text "请用 MCP 预演部署 HET PAM Node 版本 2.0.5,不要动环境"
```
也可以直接用 CLI 参数覆盖环境变量:
```bash
python -m pam_deploy_graph.cli analyze \
--config doc_scripts/config.txt.example \
--text "请用 MCP 预演部署 HET PAM Node 版本 2.0.5,不要动环境" \
--llm-base-url https://your-llm.example.com/v1 \
--llm-api-key your-api-key \
--llm-model your-model-name
```
真实 LLM 调用位置在 `pam_deploy_graph/llm/openai_compatible.py`,提示词在 `pam_deploy_graph/llm/prompts.py`。发送给 LLM 的 `base_params` 会脱敏,`CLIENT_SECRET` 不会进入 prompt本地生成计划后仍会执行 guardrails 校验。
## MCP Client 配置
真实 MCP session 由外部接入Agent 只依赖同步 `call_tool(name, arguments)` 接口。接入方式:
```python
from pam_deploy_graph.agent import PamDeployAgent
from pam_deploy_graph.mcp_client import SessionMcpToolClient, load_mcp_client_config
from pam_deploy_graph.mcp_runner import McpActionRunner
config = load_mcp_client_config("mcp_client.json")
client = SessionMcpToolClient(session) # session 是你接入真实 MCP 后得到的 SDK session
runner = McpActionRunner(client=client, tool_names=config.tool_names or None)
agent = PamDeployAgent(mcp_runner=runner)
```
`mcp_client.json` 示例:
```json
{
"server_name": "pam-node-prod",
"tool_names": {
"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"
}
}
```
如果不传 `tool_names``McpActionRunner` 会使用上面的默认 action -> tool 映射。
## 使用方式 ## 使用方式
交互式对话框:
```bash
python -m pam_deploy_graph.cli chat --config doc_scripts/config.txt.example --strategy fake --checkpoint runtime/checkpoints/chat-demo.json
```
启动后可输入自然语言需求或会话命令:
```text
PAM> 请用 MCP 预演部署 HET PAM Node 版本 2.0.5,不要动环境
PAM> preview
PAM> set VERSION_NUMBER=2.0.6
PAM> run
即将执行真实 action确认执行请输入 yes: yes
PAM> status
PAM> approve
PAM> resume
PAM> exit
```
`chat` 默认仍要求在会话内显式输入 `run``yes` 才会执行 action如果某个 IP 失败,会提示输入 `approve``reject [原因]``chat` 也支持 `--llm-base-url` / `--llm-api-key` / `--llm-model`,配置方式和 `analyze` 一致。
预演: 预演:
```bash ```bash
@ -86,9 +175,24 @@ python -m pam_deploy_graph.cli run-global --config doc_scripts/config.txt.exampl
fake 完整部署流程验证: fake 完整部署流程验证:
```bash ```bash
python -m pam_deploy_graph.cli run-deploy --config doc_scripts/config.txt.example --strategy fake --confirm python -m pam_deploy_graph.cli run-deploy --config doc_scripts/config.txt.example --strategy fake --checkpoint runtime/checkpoints/demo.json --confirm
``` ```
如果某个 IP 失败并进入待回滚确认,先查看输出中的 `confirmation`,再人工决定:
```bash
python -m pam_deploy_graph.cli confirm --checkpoint runtime/checkpoints/demo.json --decision approve --confirm
python -m pam_deploy_graph.cli resume --checkpoint runtime/checkpoints/demo.json --confirm
```
拒绝回滚:
```bash
python -m pam_deploy_graph.cli confirm --checkpoint runtime/checkpoints/demo.json --decision reject --note "人工决定暂不回滚" --confirm
```
checkpoint 用于断点续跑会保存完整运行状态和参数。为了支持真实续跑Agent 写入 checkpoint 时不会脱敏参数;请把 checkpoint 放在受控目录中。如果不传 `--checkpoint`,流程仍可运行,但不能跨进程 `resume`
结构化理解和计划生成: 结构化理解和计划生成:
```bash ```bash
@ -104,9 +208,6 @@ pytest -q
## 下一步建议 ## 下一步建议
1. 接入真实 PAM_NODE MCP session并用 `SessionMcpToolClient` 包装。 1. 接入真实 PAM_NODE MCP session并用 `SessionMcpToolClient` 包装。
2. 用 fake runner 补齐完整部署主流程和单 IP 子流程测试。 2. 在测试环境中做 smokeHOME 脚本 `get-token/get-node-url` + NODE MCP `get-online-ips`
3. 引入 LangGraph把当前 Agent 节点接入 `StateGraph` 3. 把当前 checkpoint/confirmation 语义继续接入 LangGraph interrupt/checkpointer。
4. 增加人工确认节点参数确认、IP 范围确认、回滚确认。 4. 继续细化参数确认、IP 范围确认的交互式 UI 或上层编排。
5. 接入真实 LLM 服务,实现 `RuleBasedLlmClient` 同协议替换。
6. 完善 checkpoint 恢复:全局步骤跳过、成功 IP 跳过、pending rollback 恢复。
7. 在测试环境中做 smokeHOME 脚本 `get-token/get-node-url` + NODE MCP `get-online-ips`

View File

@ -11,10 +11,11 @@ from pathlib import Path
from typing import Any from typing import Any
from .action_router import ActionRouter, build_action_backends from .action_router import ActionRouter, build_action_backends
from .checkpoint_store import save_checkpoint
from .config_writer import write_config from .config_writer import write_config
from .constants import DEFAULT_PARAMS, GLOBAL_ACTION_SEQUENCE, IP_ACTION_SEQUENCE, REQUIRED_PARAMS from .constants import DEFAULT_PARAMS, GLOBAL_ACTION_SEQUENCE, IP_ACTION_SEQUENCE, REQUIRED_PARAMS
from .fake_runner import FakeActionRunner from .fake_runner import FakeActionRunner
from .llm import RuleBasedLlmClient, validate_deploy_plan, validate_intent_result from .llm import LlmClient, RuleBasedLlmClient, validate_deploy_plan, validate_intent_result
from .mcp_runner import McpActionRunner from .mcp_runner import McpActionRunner
from .models import AgentState, ExecutionStrategy, LlmDeployPlan, LlmIntentResult, LlmParamResult from .models import AgentState, ExecutionStrategy, LlmDeployPlan, LlmIntentResult, LlmParamResult
from .script_runner import ScriptActionRunner, select_script_entry from .script_runner import ScriptActionRunner, select_script_entry
@ -29,7 +30,7 @@ class PamDeployAgent:
script_base_dir: str | Path = "doc_scripts", script_base_dir: str | Path = "doc_scripts",
mcp_runner: McpActionRunner | None = None, mcp_runner: McpActionRunner | None = None,
fake_runner: FakeActionRunner | None = None, fake_runner: FakeActionRunner | None = None,
llm_client: RuleBasedLlmClient | None = None, llm_client: LlmClient | None = None,
) -> None: ) -> None:
self.skill_policy = load_skill_policy(skill_path) self.skill_policy = load_skill_policy(skill_path)
self.script_base_dir = Path(script_base_dir) self.script_base_dir = Path(script_base_dir)
@ -98,6 +99,7 @@ class PamDeployAgent:
script_entry: str | None = None, script_entry: str | None = None,
config_path: str | None = None, config_path: str | None = None,
trace_file_path: str | None = None, trace_file_path: str | None = None,
checkpoint_path: str | None = None,
target_ips: list[str] | None = None, target_ips: list[str] | None = None,
) -> AgentState: ) -> AgentState:
normalized = self.normalize_params(params) normalized = self.normalize_params(params)
@ -116,6 +118,7 @@ class PamDeployAgent:
script_base_dir=str(self.script_base_dir), script_base_dir=str(self.script_base_dir),
config_path=actual_config_path, config_path=actual_config_path,
trace_file_path=actual_trace_path, trace_file_path=actual_trace_path,
checkpoint_path=checkpoint_path or "",
target_ips=target_ips or [], target_ips=target_ips or [],
) )
@ -151,6 +154,8 @@ class PamDeployAgent:
def run_global_flow(self, state: AgentState) -> AgentState: def run_global_flow(self, state: AgentState) -> AgentState:
for action in GLOBAL_ACTION_SEQUENCE: for action in GLOBAL_ACTION_SEQUENCE:
if action in state.completed_global_steps:
continue
kwargs: dict[str, Any] = {} kwargs: dict[str, Any] = {}
if action == "publish-version": if action == "publish-version":
kwargs["hash_code"] = state.hash_code kwargs["hash_code"] = state.hash_code
@ -165,20 +170,38 @@ class PamDeployAgent:
) )
if not result.ok: if not result.ok:
state.last_failed_step = action state.last_failed_step = action
self._save_checkpoint(state)
raise RuntimeError(f"{action} failed: {result.error_summary}") raise RuntimeError(f"{action} failed: {result.error_summary}")
self._apply_result(state, action, result.values) self._apply_result(state, action, result.values)
state.completed_global_steps.append(action) state.completed_global_steps.append(action)
state.last_success_step = action state.last_success_step = action
self._save_checkpoint(state)
return state return state
def run_deploy_flow(self, state: AgentState) -> AgentState: def run_deploy_flow(self, state: AgentState) -> AgentState:
if state.pending_confirmation:
self._save_checkpoint(state)
return state
self.run_global_flow(state) self.run_global_flow(state)
self.run_ip_flow(state) self.run_ip_flow(state)
return state return state
def run_ip_flow(self, state: AgentState) -> AgentState: def run_ip_flow(self, state: AgentState) -> AgentState:
if state.pending_confirmation:
self._save_checkpoint(state)
return state
self._resolve_target_ips(state) self._resolve_target_ips(state)
for ip in state.target_ips: for ip in state.target_ips:
ip_state = state.ip_states.get(ip)
if ip_state and ip_state.get("status") == "SUCCESS":
continue
if ip_state and ip_state.get("status") == "FAILED":
if ip_state.get("rollback_status") == "PENDING_AGENT_CONFIRMATION":
state.pending_confirmation = f"rollback-ip:{ip}"
self._save_checkpoint(state)
return state
continue
if not ip_state:
state.events.append({"type": "IP_START", "ip": ip, "message": "start"}) state.events.append({"type": "IP_START", "ip": ip, "message": "start"})
ip_state = { ip_state = {
"status": "RUNNING", "status": "RUNNING",
@ -192,6 +215,9 @@ class PamDeployAgent:
state.ip_states[ip] = ip_state state.ip_states[ip] = ip_state
for action in IP_ACTION_SEQUENCE: for action in IP_ACTION_SEQUENCE:
completed_steps = ip_state.setdefault("completed_steps", [])
if action in completed_steps:
continue
result = self.router.run_action(state, action, ip=ip) result = self.router.run_action(state, action, ip=ip)
failed = (not result.ok) or self._business_failed(action, result.values) failed = (not result.ok) or self._business_failed(action, result.values)
state.events.append( state.events.append(
@ -209,13 +235,85 @@ class PamDeployAgent:
if action != "download-log": if action != "download-log":
self._download_log_best_effort(state, ip) self._download_log_best_effort(state, ip)
state.pending_confirmation = f"rollback-ip:{ip}" state.pending_confirmation = f"rollback-ip:{ip}"
self._save_checkpoint(state)
return state return state
self._apply_ip_result(ip_state, action, result.values) self._apply_ip_result(ip_state, action, result.values)
ip_state["completed_steps"].append(action) completed_steps.append(action)
self._save_checkpoint(state)
ip_state["status"] = "SUCCESS" ip_state["status"] = "SUCCESS"
state.events.append({"type": "IP_DONE", "ip": ip, "message": "success"}) state.events.append({"type": "IP_DONE", "ip": ip, "message": "success"})
self._save_checkpoint(state)
return state
def build_confirmation_request(self, state: AgentState) -> dict[str, Any]:
if not state.pending_confirmation:
return {}
kind, _, value = state.pending_confirmation.partition(":")
if kind == "rollback-ip":
ip_state = state.ip_states.get(value, {})
return {
"type": "rollback-ip",
"ip": value,
"failed_stage": ip_state.get("failed_stage", ""),
"failure_reason": ip_state.get("failure_reason", ""),
"rollback_stop_first": bool(ip_state.get("rollback_stop_first", False)),
"allowed_decisions": ["approve", "reject"],
}
return {
"type": kind,
"value": value,
"allowed_decisions": ["approve", "reject"],
}
def confirm_pending(self, state: AgentState, *, approved: bool, operator_note: str = "") -> AgentState:
request = self.build_confirmation_request(state)
if not request:
raise ValueError("No pending confirmation")
if request["type"] != "rollback-ip":
raise ValueError(f"Unsupported confirmation type: {request['type']}")
ip = request["ip"]
ip_state = state.ip_states[ip]
if not approved:
ip_state["rollback_status"] = "REJECTED_BY_OPERATOR"
state.events.append(
{
"type": "CONFIRMATION_REJECTED",
"stage": "rollback-ip",
"ip": ip,
"message": operator_note or "rollback rejected by operator",
}
)
state.pending_confirmation = ""
self._save_checkpoint(state)
return state
result = self.router.run_action(
state,
"rollback-ip",
ip=ip,
stop_first=bool(ip_state.get("rollback_stop_first", False)),
)
ip_state["rollback_status"] = "ROLLBACK_DONE" if result.ok else "ROLLBACK_FAILED"
state.events.append(
{
"type": "ACTION_DONE" if result.ok else "ACTION_FAIL",
"stage": "rollback-ip",
"backend": result.backend,
"ip": ip,
"message": result.error_summary or result.values.get("MESSAGE", "ok"),
}
)
if result.ok:
state.pending_confirmation = ""
state.last_success_step = "rollback-ip"
state.last_failed_step = ""
else:
state.pending_confirmation = f"rollback-ip:{ip}"
state.last_failed_step = "rollback-ip"
self._save_checkpoint(state)
return state return state
def _apply_result(self, state: AgentState, action: str, values: dict[str, Any]) -> None: def _apply_result(self, state: AgentState, action: str, values: dict[str, Any]) -> None:
@ -308,6 +406,10 @@ class PamDeployAgent:
} }
) )
def _save_checkpoint(self, state: AgentState) -> None:
if state.checkpoint_path:
save_checkpoint(state, state.checkpoint_path, redact=False)
def render_report(self, state: AgentState) -> str: def render_report(self, state: AgentState) -> str:
success = sum(1 for item in state.ip_states.values() if item.get("status") == "SUCCESS") success = sum(1 for item in state.ip_states.values() if item.get("status") == "SUCCESS")
failed = sum(1 for item in state.ip_states.values() if item.get("status") == "FAILED") failed = sum(1 for item in state.ip_states.values() if item.get("status") == "FAILED")

View File

@ -3,11 +3,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
from dataclasses import asdict, is_dataclass from dataclasses import asdict, fields, is_dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .constants import SENSITIVE_KEYS from .constants import SENSITIVE_KEYS
from .models import AgentState
def redact_mapping(value: Any) -> Any: def redact_mapping(value: Any) -> Any:
@ -24,12 +25,14 @@ def redact_mapping(value: Any) -> Any:
return value return value
def save_checkpoint(state: Any, path: str | Path) -> Path: def save_checkpoint(state: Any, path: str | Path, *, redact: bool = True) -> Path:
checkpoint_path = Path(path) checkpoint_path = Path(path)
checkpoint_path.parent.mkdir(parents=True, exist_ok=True) checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
payload = asdict(state) if is_dataclass(state) else state payload = asdict(state) if is_dataclass(state) else state
if redact:
payload = redact_mapping(payload)
checkpoint_path.write_text( checkpoint_path.write_text(
json.dumps(redact_mapping(payload), ensure_ascii=False, indent=2), json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8", encoding="utf-8",
) )
return checkpoint_path return checkpoint_path
@ -38,3 +41,12 @@ def save_checkpoint(state: Any, path: str | Path) -> Path:
def load_checkpoint(path: str | Path) -> dict[str, Any]: def load_checkpoint(path: str | Path) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8")) return json.loads(Path(path).read_text(encoding="utf-8"))
def agent_state_from_mapping(payload: dict[str, Any]) -> AgentState:
allowed_fields = {item.name for item in fields(AgentState)}
state_payload = {key: value for key, value in payload.items() if key in allowed_fields}
return AgentState(**state_payload)
def load_agent_state(path: str | Path) -> AgentState:
return agent_state_from_mapping(load_checkpoint(path))

View File

@ -7,10 +7,30 @@ import json
from dataclasses import asdict from dataclasses import asdict
from .agent import PamDeployAgent from .agent import PamDeployAgent
from .checkpoint_store import redact_mapping from .checkpoint_store import load_agent_state, redact_mapping
from .interactive import run_interactive_chat
from .llm import build_llm_client
from .params_loader import load_params_file from .params_loader import load_params_file
def add_llm_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--llm-base-url")
parser.add_argument("--llm-api-key")
parser.add_argument("--llm-model")
def require_confirm(args: argparse.Namespace) -> None:
if not getattr(args, "confirm", False):
raise SystemExit("Refusing to execute actions without --confirm.")
def print_pause_payload(agent: PamDeployAgent, state) -> None:
if state.pending_confirmation:
print(json.dumps({"confirmation": agent.build_confirmation_request(state)}, ensure_ascii=False, indent=2))
if state.checkpoint_path:
print(json.dumps({"checkpoint": state.checkpoint_path}, ensure_ascii=False, indent=2))
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(prog="pam-deploy-agent") parser = argparse.ArgumentParser(prog="pam-deploy-agent")
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
@ -22,21 +42,48 @@ def main() -> None:
analyze = sub.add_parser("analyze") analyze = sub.add_parser("analyze")
analyze.add_argument("--text", required=True) analyze.add_argument("--text", required=True)
analyze.add_argument("--config") analyze.add_argument("--config")
add_llm_args(analyze)
chat = sub.add_parser("chat")
chat.add_argument("--config", required=True)
chat.add_argument("--strategy", default="fake", choices=["hybrid_node_mcp", "script_only", "fake"])
chat.add_argument("--target-ip", action="append", default=[])
chat.add_argument("--checkpoint")
add_llm_args(chat)
run = sub.add_parser("run-global") run = sub.add_parser("run-global")
run.add_argument("--config", required=True) run.add_argument("--config", required=True)
run.add_argument("--strategy", default="fake", choices=["hybrid_node_mcp", "script_only", "fake"]) run.add_argument("--strategy", default="fake", choices=["hybrid_node_mcp", "script_only", "fake"])
run.add_argument("--checkpoint")
run.add_argument("--confirm", action="store_true") run.add_argument("--confirm", action="store_true")
deploy = sub.add_parser("run-deploy") deploy = sub.add_parser("run-deploy")
deploy.add_argument("--config", required=True) deploy.add_argument("--config", required=True)
deploy.add_argument("--strategy", default="fake", choices=["hybrid_node_mcp", "script_only", "fake"]) deploy.add_argument("--strategy", default="fake", choices=["hybrid_node_mcp", "script_only", "fake"])
deploy.add_argument("--target-ip", action="append", default=[]) deploy.add_argument("--target-ip", action="append", default=[])
deploy.add_argument("--checkpoint")
deploy.add_argument("--confirm", action="store_true") deploy.add_argument("--confirm", action="store_true")
resume = sub.add_parser("resume")
resume.add_argument("--checkpoint", required=True)
resume.add_argument("--confirm", action="store_true")
confirm = sub.add_parser("confirm")
confirm.add_argument("--checkpoint", required=True)
confirm.add_argument("--decision", required=True, choices=["approve", "reject"])
confirm.add_argument("--note", default="")
confirm.add_argument("--confirm", action="store_true")
args = parser.parse_args() args = parser.parse_args()
params = load_params_file(args.config) if getattr(args, "config", None) else {} params = load_params_file(args.config) if getattr(args, "config", None) else {}
agent = PamDeployAgent() llm_client = None
if args.command in ("analyze", "chat"):
llm_client = build_llm_client(
base_url=args.llm_base_url,
api_key=args.llm_api_key,
model=args.llm_model,
)
agent = PamDeployAgent(llm_client=llm_client)
if args.command == "analyze": if args.command == "analyze":
result = agent.analyze_request(args.text, params) result = agent.analyze_request(args.text, params)
@ -44,25 +91,61 @@ def main() -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2)) print(json.dumps(payload, ensure_ascii=False, indent=2))
return return
if args.command == "chat":
run_interactive_chat(
agent=agent,
params=params,
strategy=args.strategy,
checkpoint_path=args.checkpoint,
target_ips=args.target_ip,
)
return
if args.command == "preview": if args.command == "preview":
print(agent.preview(params, args.strategy)) print(agent.preview(params, args.strategy))
return return
if not args.confirm: require_confirm(args)
raise SystemExit("Refusing to execute actions without --confirm.")
if args.command == "run-global": if args.command == "run-global":
state = agent.create_state(params=params, execution_strategy=args.strategy) state = agent.create_state(
params=params,
execution_strategy=args.strategy,
checkpoint_path=args.checkpoint,
)
state = agent.run_global_flow(state) state = agent.run_global_flow(state)
print(json.dumps({"events": state.events}, ensure_ascii=False, indent=2)) print(json.dumps({"events": state.events}, ensure_ascii=False, indent=2))
print_pause_payload(agent, state)
return
if args.command == "resume":
state = load_agent_state(args.checkpoint)
state.checkpoint_path = state.checkpoint_path or args.checkpoint
state = agent.run_deploy_flow(state)
print(agent.render_report(state))
print_pause_payload(agent, state)
return
if args.command == "confirm":
state = load_agent_state(args.checkpoint)
state.checkpoint_path = state.checkpoint_path or args.checkpoint
state = agent.confirm_pending(
state,
approved=args.decision == "approve",
operator_note=args.note,
)
print(agent.render_report(state))
print_pause_payload(agent, state)
return return
state = agent.create_state( state = agent.create_state(
params=params, params=params,
execution_strategy=args.strategy, execution_strategy=args.strategy,
checkpoint_path=args.checkpoint,
target_ips=args.target_ip, target_ips=args.target_ip,
) )
state = agent.run_deploy_flow(state) state = agent.run_deploy_flow(state)
print(agent.render_report(state)) print(agent.render_report(state))
print_pause_payload(agent, state)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -0,0 +1,290 @@
"""Interactive CLI session for the PAM deploy agent."""
from __future__ import annotations
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any, Callable
from .agent import PamDeployAgent
from .checkpoint_store import load_agent_state, redact_mapping
from .models import AgentState, ExecutionStrategy
InputFunc = Callable[[str], str]
OutputFunc = Callable[[str], None]
COMMAND_HELP = """可用命令:
help 显示帮助
preview 查看当前参数和执行策略
analyze <需求> 只做理解和计划不执行
set KEY=VALUE 修改当前会话参数
run 创建部署任务并执行
status 查看当前运行状态
approve 确认待处理回滚
reject [原因] 拒绝待处理回滚
resume 从当前 checkpoint 续跑
checkpoint 显示 checkpoint 路径
exit 退出
也可以直接输入自然语言需求Agent 会先分析并更新会话参数执行仍需输入 run
"""
class InteractiveCliSession:
def __init__(
self,
*,
agent: PamDeployAgent,
params: dict[str, Any],
strategy: ExecutionStrategy = "hybrid_node_mcp",
checkpoint_path: str | None = None,
target_ips: list[str] | None = None,
input_func: InputFunc = input,
output_func: OutputFunc = print,
) -> None:
self.agent = agent
self.params = dict(params)
self.strategy = strategy
self.checkpoint_path = checkpoint_path or _default_checkpoint_path()
self.target_ips = list(target_ips or [])
self.input = input_func
self.output = output_func
self.state: AgentState | None = None
self.last_analysis: dict[str, Any] | None = None
def run(self) -> None:
self.output("PAM Deploy Agent interactive session")
self.output("输入 help 查看命令,输入 exit 退出。")
self._load_existing_checkpoint_if_any()
while True:
try:
line = self.input("PAM> ")
except EOFError:
self.output("bye")
return
if not self.handle_line(line):
return
def handle_line(self, line: str) -> bool:
text = line.strip()
if not text:
return True
command, _, rest = text.partition(" ")
normalized = command.lower()
if normalized in ("exit", "quit", "q"):
self.output("bye")
return False
if normalized in ("help", "?"):
self.output(COMMAND_HELP.rstrip())
return True
if normalized == "preview":
self.output(self.agent.preview(self.params, self.strategy))
return True
if normalized == "analyze":
self._analyze(rest.strip())
return True
if normalized == "set":
self._set_param(rest.strip())
return True
if normalized in ("run", "deploy", "execute"):
self._run_deploy()
return True
if normalized == "resume":
self._resume()
return True
if normalized == "status":
self._status()
return True
if normalized == "approve":
self._confirm(approved=True, note=rest.strip())
return True
if normalized == "reject":
self._confirm(approved=False, note=rest.strip())
return True
if normalized == "checkpoint":
self.output(f"checkpoint: {self.checkpoint_path}")
return True
self._analyze(text)
return True
def _analyze(self, text: str) -> None:
if not text:
self.output("请输入要分析的自然语言需求例如analyze 请用 MCP 预演部署 HET。")
return
result = self.agent.analyze_request(text, self.params)
self.last_analysis = result
param_result = result["params"]
intent_result = result["intent"]
plan = result["plan"]
self.params = dict(param_result.extracted_params)
self.strategy = _choose_strategy(intent_result.strategy_preference, self.strategy)
user_ips = param_result.extracted_control.get("user_specified_ips")
if isinstance(user_ips, list):
self.target_ips = [str(item) for item in user_ips]
safe_payload = redact_mapping({key: asdict(value) for key, value in result.items()})
self.output("已生成结构化理解:")
self.output(f"- intent: {intent_result.intent}")
self.output(f"- strategy: {self.strategy}")
self.output(f"- summary: {plan.summary}")
if param_result.missing_required_params:
self.output("- missing: " + ", ".join(param_result.missing_required_params))
if self.target_ips:
self.output("- target_ips: " + ", ".join(self.target_ips))
self.output("执行请输 run查看完整 JSON 可用一次性 analyze 命令。")
self.output(_format_redacted_params(safe_payload["params"]["extracted_params"]))
def _set_param(self, assignment: str) -> None:
if "=" not in assignment:
self.output("格式set KEY=VALUE")
return
key, value = assignment.split("=", 1)
key = key.strip()
if not key:
self.output("参数名不能为空。")
return
self.params[key] = value.strip()
self.output(f"已设置 {key}")
def _run_deploy(self) -> None:
if self.state and self.state.pending_confirmation:
self._print_confirmation()
return
if not self._ask_yes_no("即将执行真实 action确认执行请输入 yes: "):
self.output("已取消执行。")
return
self.state = self.agent.create_state(
params=self.params,
execution_strategy=self.strategy,
checkpoint_path=self.checkpoint_path,
target_ips=self.target_ips,
)
self._execute_current_state()
def _resume(self) -> None:
if self.state is None:
checkpoint = Path(self.checkpoint_path)
if not checkpoint.exists():
self.output("当前没有可续跑的 checkpoint。")
return
self.state = load_agent_state(checkpoint)
self.state.checkpoint_path = self.state.checkpoint_path or str(checkpoint)
self._execute_current_state()
def _execute_current_state(self) -> None:
if self.state is None:
self.output("当前没有运行状态。")
return
self.state = self.agent.run_deploy_flow(self.state)
self.output(self.agent.render_report(self.state))
if self.state.pending_confirmation:
self._print_confirmation()
self.output(f"checkpoint: {self.state.checkpoint_path or self.checkpoint_path}")
def _status(self) -> None:
if self.state is None:
self.output("当前还没有运行状态。")
self.output(f"checkpoint: {self.checkpoint_path}")
return
self.output(self.agent.render_report(self.state))
if self.state.pending_confirmation:
self._print_confirmation()
def _confirm(self, *, approved: bool, note: str = "") -> None:
if self.state is None:
checkpoint = Path(self.checkpoint_path)
if checkpoint.exists():
self.state = load_agent_state(checkpoint)
self.state.checkpoint_path = self.state.checkpoint_path or str(checkpoint)
else:
self.output("当前没有待确认任务。")
return
if not self.state.pending_confirmation:
self.output("当前没有待确认任务。")
return
self.state = self.agent.confirm_pending(self.state, approved=approved, operator_note=note)
self.output(self.agent.render_report(self.state))
if self.state.pending_confirmation:
self._print_confirmation()
def _print_confirmation(self) -> None:
if self.state is None:
return
request = self.agent.build_confirmation_request(self.state)
if not request:
return
self.output("需要人工确认:")
self.output(f"- type: {request.get('type')}")
if request.get("ip"):
self.output(f"- ip: {request['ip']}")
if request.get("failed_stage"):
self.output(f"- failed_stage: {request['failed_stage']}")
if request.get("failure_reason"):
self.output(f"- reason: {request['failure_reason']}")
self.output("输入 approve 执行回滚,或 reject [原因] 拒绝回滚。")
def _ask_yes_no(self, prompt: str) -> bool:
try:
answer = self.input(prompt).strip().lower()
except EOFError:
return False
return answer in ("yes", "y")
def _load_existing_checkpoint_if_any(self) -> None:
checkpoint = Path(self.checkpoint_path)
if not checkpoint.exists():
return
self.state = load_agent_state(checkpoint)
self.state.checkpoint_path = self.state.checkpoint_path or str(checkpoint)
self.output(f"已加载 checkpoint: {checkpoint}")
if self.state.pending_confirmation:
self._print_confirmation()
def run_interactive_chat(
*,
agent: PamDeployAgent,
params: dict[str, Any],
strategy: ExecutionStrategy,
checkpoint_path: str | None = None,
target_ips: list[str] | None = None,
input_func: InputFunc = input,
output_func: OutputFunc = print,
) -> InteractiveCliSession:
session = InteractiveCliSession(
agent=agent,
params=params,
strategy=strategy,
checkpoint_path=checkpoint_path,
target_ips=target_ips,
input_func=input_func,
output_func=output_func,
)
session.run()
return session
def _default_checkpoint_path() -> str:
return str(Path("runtime") / "checkpoints" / f"chat_{time.strftime('%Y%m%d_%H%M%S')}.json")
def _choose_strategy(preference: str, default: ExecutionStrategy) -> ExecutionStrategy:
if preference in ("hybrid_node_mcp", "script_only", "fake"):
return preference # type: ignore[return-value]
return default
def _format_redacted_params(params: dict[str, Any]) -> str:
lines = ["当前参数:"]
for key in sorted(params):
lines.append(f"- {key}: {params[key]}")
return "\n".join(lines)

View File

@ -1,7 +1,16 @@
"""LLM integration surfaces for PAM deploy Agent.""" """LLM integration surfaces for PAM deploy Agent."""
from .base import LlmClient
from .factory import build_llm_client
from .openai_compatible import OpenAICompatibleLlmClient
from .rule_based import RuleBasedLlmClient from .rule_based import RuleBasedLlmClient
from .validators import validate_deploy_plan, validate_intent_result from .validators import validate_deploy_plan, validate_intent_result
__all__ = ["RuleBasedLlmClient", "validate_deploy_plan", "validate_intent_result"] __all__ = [
"LlmClient",
"OpenAICompatibleLlmClient",
"RuleBasedLlmClient",
"build_llm_client",
"validate_deploy_plan",
"validate_intent_result",
]

View File

@ -0,0 +1,24 @@
"""Shared LLM client protocol."""
from __future__ import annotations
from typing import Any, Protocol
from pam_deploy_graph.models import ExecutionStrategy, LlmDeployPlan, LlmIntentResult, LlmParamResult
class LlmClient(Protocol):
def understand_request(self, text: str) -> LlmIntentResult:
...
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
...
def generate_plan(
self,
*,
params: dict[str, Any],
intent: str,
strategy: ExecutionStrategy,
) -> LlmDeployPlan:
...

View File

@ -0,0 +1,39 @@
"""LLM client factory for CLI and embedding code."""
from __future__ import annotations
import os
from .base import LlmClient
from .openai_compatible import OpenAICompatibleLlmClient
from .rule_based import RuleBasedLlmClient
def build_llm_client(
*,
base_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
) -> LlmClient:
actual_base_url = base_url or os.getenv("PAM_LLM_BASE_URL", "")
actual_api_key = api_key or os.getenv("PAM_LLM_API_KEY", "")
actual_model = model or os.getenv("PAM_LLM_MODEL", "")
if not actual_base_url and not actual_api_key and not actual_model:
return RuleBasedLlmClient()
missing = []
if not actual_base_url:
missing.append("base_url")
if not actual_api_key:
missing.append("api_key")
if not actual_model:
missing.append("model")
if missing:
raise ValueError(f"Incomplete LLM config: missing {', '.join(missing)}")
return OpenAICompatibleLlmClient(
base_url=actual_base_url,
api_key=actual_api_key,
model=actual_model,
)

View File

@ -0,0 +1,242 @@
"""OpenAI-compatible HTTP LLM client.
The client targets providers exposing a `/chat/completions` endpoint with
OpenAI-style request and response shapes. It intentionally uses only the Python
standard library so the runtime can stay dependency-light.
"""
from __future__ import annotations
import json
import urllib.request
from collections.abc import Callable
from typing import Any
from pam_deploy_graph.constants import (
ALLOWED_ACTIONS,
DEFAULT_PARAMS,
GLOBAL_ACTION_SEQUENCE,
IP_ACTION_SEQUENCE,
REQUIRED_PARAMS,
SENSITIVE_KEYS,
)
from pam_deploy_graph.models import ExecutionStrategy, LlmDeployPlan, LlmIntentResult, LlmParamResult
from .prompts import INTENT_PROMPT, PARAM_PROMPT, PLAN_PROMPT, SYSTEM_PROMPT
JsonTransport = Callable[[str, dict[str, str], dict[str, Any], float], dict[str, Any]]
class OpenAICompatibleLlmClient:
def __init__(
self,
*,
base_url: str,
api_key: str,
model: str,
timeout_sec: float = 30,
temperature: float = 0,
transport: JsonTransport | None = None,
) -> None:
if not base_url:
raise ValueError("LLM base_url is required")
if not api_key:
raise ValueError("LLM api_key is required")
if not model:
raise ValueError("LLM model is required")
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self.timeout_sec = timeout_sec
self.temperature = temperature
self.transport = transport or _default_transport
def understand_request(self, text: str) -> LlmIntentResult:
payload = self._complete_json(INTENT_PROMPT, {"user_text": text})
return LlmIntentResult(
intent=_string(payload, "intent", "deploy"), # type: ignore[arg-type]
mode_preference=_string(payload, "mode_preference", "未指定"), # type: ignore[arg-type]
strategy_preference=_string(payload, "strategy_preference", "未指定"), # type: ignore[arg-type]
confidence=_float(payload, "confidence", 0.0),
reasons=_string_list(payload.get("reasons")),
needs_clarification=bool(payload.get("needs_clarification", False)),
clarification_questions=_string_list(payload.get("clarification_questions")),
)
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
original_base = dict(base_params or {})
safe_base = _redact_sensitive(original_base)
payload = self._complete_json(
PARAM_PROMPT,
{
"user_text": text,
"base_params": safe_base,
"required_params": list(REQUIRED_PARAMS),
"default_params": DEFAULT_PARAMS,
},
)
extracted = _dict(payload.get("extracted_params"))
merged = original_base.copy()
for key, value in extracted.items():
if key in SENSITIVE_KEYS and value == "***":
continue
merged[key] = value
control = _dict(payload.get("extracted_control"))
missing = [key for key in REQUIRED_PARAMS if not merged.get(key)]
sensitive = [key for key in ("CLIENT_SECRET", "CLIENT_ID") if merged.get(key)]
llm_ambiguous = _string_list(payload.get("ambiguous_fields"))
return LlmParamResult(
extracted_params=merged,
extracted_control=control,
missing_required_params=missing,
ambiguous_fields=llm_ambiguous,
sensitive_fields_present=sensitive,
)
def generate_plan(
self,
*,
params: dict[str, Any],
intent: str,
strategy: ExecutionStrategy,
) -> LlmDeployPlan:
payload = self._complete_json(
PLAN_PROMPT,
{
"params": _redact_sensitive(params),
"intent": intent,
"execution_strategy": strategy,
"allowed_actions": list(ALLOWED_ACTIONS),
"global_action_sequence": list(GLOBAL_ACTION_SEQUENCE),
"ip_action_sequence": list(IP_ACTION_SEQUENCE),
},
)
planned_actions = _string_list(payload.get("planned_actions")) or list(GLOBAL_ACTION_SEQUENCE)
return LlmDeployPlan(
summary=_string(payload, "summary", "PAM deployment plan"),
risk_notes=_string_list(payload.get("risk_notes")),
planned_actions=planned_actions,
requires_confirmation=bool(payload.get("requires_confirmation", True)),
execution_strategy=_string(payload, "execution_strategy", strategy), # type: ignore[arg-type]
)
def _complete_json(self, instruction: str, input_payload: dict[str, Any]) -> dict[str, Any]:
request_payload = {
"model": self.model,
"temperature": self.temperature,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": instruction
+ "\n\n输入 JSON:\n"
+ json.dumps(input_payload, ensure_ascii=False, sort_keys=True),
},
],
}
response = self.transport(
_chat_completions_url(self.base_url),
{
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
request_payload,
self.timeout_sec,
)
content = _message_content(response)
parsed = _loads_json_object(content)
if not isinstance(parsed, dict):
raise ValueError("LLM response must be a JSON object")
return parsed
def _default_transport(
url: str,
headers: dict[str, str],
payload: dict[str, Any],
timeout_sec: float,
) -> dict[str, Any]:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout_sec) as response:
raw = response.read().decode("utf-8")
decoded = json.loads(raw)
if not isinstance(decoded, dict):
raise ValueError("LLM HTTP response must be a JSON object")
return decoded
def _chat_completions_url(base_url: str) -> str:
clean = base_url.rstrip("/")
if clean.endswith("/chat/completions"):
return clean
return f"{clean}/chat/completions"
def _message_content(response: dict[str, Any]) -> Any:
try:
content = response["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise ValueError("LLM response does not contain choices[0].message.content") from exc
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(str(item.get("text", "")))
elif isinstance(item, str):
parts.append(item)
return "".join(parts)
return content
def _loads_json_object(content: Any) -> Any:
if isinstance(content, dict):
return content
if not isinstance(content, str):
raise ValueError("LLM message content must be JSON text")
return json.loads(content)
def _redact_sensitive(value: Any) -> Any:
if isinstance(value, dict):
redacted: dict[str, Any] = {}
for key, item in value.items():
if str(key) in SENSITIVE_KEYS:
redacted[str(key)] = "***"
else:
redacted[str(key)] = _redact_sensitive(item)
return redacted
if isinstance(value, list):
return [_redact_sensitive(item) for item in value]
return value
def _string(payload: dict[str, Any], key: str, default: str) -> str:
value = payload.get(key, default)
return str(value) if value is not None else default
def _float(payload: dict[str, Any], key: str, default: float) -> float:
try:
return float(payload.get(key, default))
except (TypeError, ValueError):
return default
def _dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _string_list(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item) for item in value]
if value in (None, ""):
return []
return [str(value)]

View File

@ -0,0 +1,67 @@
"""Prompts for structured PAM deployment planning."""
SYSTEM_PROMPT = """你是 PAM 智能部署 Agent 的结构化理解与规划组件。
必须遵守
- 只输出一个 JSON 对象不输出 Markdown解释文字或代码块
- 不生成 shellPowerShellbatcurl 等可执行命令
- 不回显密钥tokenCLIENT_SECRETAuthorization 等敏感值
- 只能在允许的 action 集合中选择部署动作
- 真实执行前必须保留人工确认点参数确认目标 IP 范围确认失败回滚确认
"""
INTENT_PROMPT = """根据用户输入识别意图和执行偏好。
输出 JSON schema
{
"intent": "deploy|show_usage|preview|query_node_ips|rollback",
"mode_preference": "MCP|API脚本|未指定",
"strategy_preference": "hybrid_node_mcp|script_only|fake|未指定",
"confidence": 0.0,
"reasons": ["..."],
"needs_clarification": false,
"clarification_questions": ["..."]
}
"""
PARAM_PROMPT = """从用户输入中抽取 PAM 部署参数和控制信息。
输出 JSON schema
{
"extracted_params": {
"HOME_BASE_URL": "...",
"CLIENT_ID": "...",
"AIRPORT_CODE": "...",
"APP_NAME": "...",
"MODULE_NAME": "...",
"VERSION_NUMBER": "...",
"ZIP_FILE_PATH": "...",
"ACTION_TYPE": "...",
"TIMEOUT": "...",
"LOG_NAME": "..."
},
"extracted_control": {
"user_specified_ips": ["..."]
},
"missing_required_params": ["..."],
"ambiguous_fields": ["..."],
"sensitive_fields_present": ["..."]
}
不要输出或猜测 CLIENT_SECRET 的真实值如果输入里出现敏感字段只标记字段名
"""
PLAN_PROMPT = """生成 PAM 部署计划。
输出 JSON schema
{
"summary": "...",
"risk_notes": ["..."],
"planned_actions": ["get-token", "create-version"],
"requires_confirmation": true,
"execution_strategy": "hybrid_node_mcp|script_only|fake|未指定"
}
计划只能使用允许 action不要包含可执行脚本命令命令行参数或密钥
PAM_HOME action 仍由脚本 action 执行PAM_NODE action hybrid_node_mcp 策略下走 MCP
"""

View File

@ -9,9 +9,36 @@ from __future__ import annotations
import json import json
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any 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: class FunctionMcpToolClient:
"""Wrap a plain Python callable as an MCP tool client.""" """Wrap a plain Python callable as an MCP tool client."""

View File

@ -99,4 +99,5 @@ class AgentState:
pending_confirmation: str = "" pending_confirmation: str = ""
last_success_step: str = "" last_success_step: str = ""
last_failed_step: str = "" last_failed_step: str = ""
checkpoint_path: str = ""
events: list[dict[str, Any]] = field(default_factory=list) events: list[dict[str, Any]] = field(default_factory=list)

View File

@ -1,6 +1,8 @@
from pathlib import Path from pathlib import Path
from pam_deploy_graph.agent import PamDeployAgent from pam_deploy_graph.agent import PamDeployAgent
from pam_deploy_graph.checkpoint_store import load_agent_state
from pam_deploy_graph.constants import GLOBAL_ACTION_SEQUENCE
from pam_deploy_graph.fake_runner import FakeActionRunner from pam_deploy_graph.fake_runner import FakeActionRunner
@ -56,3 +58,96 @@ def test_run_deploy_flow_stops_on_verify_failure(tmp_path: Path):
assert state.ip_states["192.168.1.10"]["rollback_status"] == "PENDING_AGENT_CONFIRMATION" assert state.ip_states["192.168.1.10"]["rollback_status"] == "PENDING_AGENT_CONFIRMATION"
assert "192.168.1.11" not in state.ip_states assert "192.168.1.11" not in state.ip_states
assert any(event["type"] == "CONFIRMATION_REQUIRED" for event in state.events) assert any(event["type"] == "CONFIRMATION_REQUIRED" for event in state.events)
def test_confirm_pending_rollback_runs_rollback_and_resume_continues(tmp_path: Path):
fake = FakeActionRunner(
{
"verify-ip:192.168.1.10": {
"ACTION": "verify-ip",
"IP": "192.168.1.10",
"SUCCESS": "false",
"MESSAGE": "health check failed",
}
}
)
agent = PamDeployAgent(fake_runner=fake)
state = agent.create_state(
params=PARAMS,
execution_strategy="fake",
config_path=str(tmp_path / "config.txt"),
)
agent.run_deploy_flow(state)
request = agent.build_confirmation_request(state)
agent.confirm_pending(state, approved=True)
agent.run_deploy_flow(state)
assert request["type"] == "rollback-ip"
assert state.pending_confirmation == ""
assert state.ip_states["192.168.1.10"]["rollback_status"] == "ROLLBACK_DONE"
assert state.ip_states["192.168.1.11"]["status"] == "SUCCESS"
assert any(call[0] == "rollback-ip" for call in fake.calls)
def test_failed_rollback_keeps_confirmation_pending(tmp_path: Path):
fake = FakeActionRunner(
{
"verify-ip:192.168.1.10": {
"ACTION": "verify-ip",
"IP": "192.168.1.10",
"SUCCESS": "false",
"MESSAGE": "health check failed",
},
"rollback-ip:192.168.1.10": {
"_fail": True,
"ACTION": "rollback-ip",
"IP": "192.168.1.10",
"MESSAGE": "rollback failed",
},
}
)
agent = PamDeployAgent(fake_runner=fake)
state = agent.create_state(
params=PARAMS,
execution_strategy="fake",
config_path=str(tmp_path / "config.txt"),
)
agent.run_deploy_flow(state)
agent.confirm_pending(state, approved=True)
assert state.pending_confirmation == "rollback-ip:192.168.1.10"
assert state.ip_states["192.168.1.10"]["rollback_status"] == "ROLLBACK_FAILED"
def test_checkpoint_resume_skips_completed_global_and_success_ip(tmp_path: Path):
checkpoint = tmp_path / "checkpoint.json"
fake = FakeActionRunner()
agent = PamDeployAgent(fake_runner=fake)
state = agent.create_state(
params=PARAMS,
execution_strategy="fake",
config_path=str(tmp_path / "config.txt"),
checkpoint_path=str(checkpoint),
)
state.completed_global_steps = list(GLOBAL_ACTION_SEQUENCE)
state.online_ips = ["192.168.1.10", "192.168.1.11"]
state.target_ips = ["192.168.1.10", "192.168.1.11"]
state.ip_states["192.168.1.10"] = {
"status": "SUCCESS",
"completed_steps": ["upgrade-ip", "poll-upgrade-progress", "start-ip", "verify-ip", "download-log"],
"failed_stage": "",
"failure_reason": "",
"rollback_status": "ROLLBACK_NOT_RUN",
"rollback_stop_first": False,
"log_file": "logs/fake.zip",
}
agent.run_deploy_flow(state)
loaded = load_agent_state(checkpoint)
called_actions = [call[0] for call in fake.calls]
assert "get-token" not in called_actions
assert all(call[1].get("ip") != "192.168.1.10" for call in fake.calls)
assert loaded.ip_states["192.168.1.11"]["status"] == "SUCCESS"

View File

@ -0,0 +1,84 @@
from pathlib import Path
from pam_deploy_graph.agent import PamDeployAgent
from pam_deploy_graph.fake_runner import FakeActionRunner
from pam_deploy_graph.interactive import InteractiveCliSession
PARAMS = {
"HOME_BASE_URL": "https://pam.home.example.com",
"CLIENT_ID": "client",
"CLIENT_SECRET": "secret",
"AIRPORT_CODE": "HET",
"APP_NAME": "PAM",
"MODULE_NAME": "Node",
"VERSION_NUMBER": "2.0.5",
"ZIP_FILE_PATH": "C:/pkg.zip",
}
def run_session(session: InteractiveCliSession, inputs: list[str]) -> list[str]:
output: list[str] = []
iterator = iter(inputs)
session.input = lambda _prompt: next(iterator)
session.output = output.append
session.run()
return output
def test_chat_analyzes_natural_language_and_updates_context(tmp_path: Path):
session = InteractiveCliSession(
agent=PamDeployAgent(),
params=PARAMS,
strategy="fake",
checkpoint_path=str(tmp_path / "checkpoint.json"),
)
output = run_session(session, ["analyze please use MCP deploy 192.168.1.10", "exit"])
assert session.strategy == "hybrid_node_mcp"
assert session.target_ips == ["192.168.1.10"]
assert any("执行请输 run" in item for item in output)
def test_chat_run_executes_fake_deploy_and_writes_checkpoint(tmp_path: Path):
checkpoint = tmp_path / "checkpoint.json"
session = InteractiveCliSession(
agent=PamDeployAgent(fake_runner=FakeActionRunner()),
params=PARAMS,
strategy="fake",
checkpoint_path=str(checkpoint),
)
run_session(session, ["run", "yes", "exit"])
assert checkpoint.exists()
assert session.state is not None
assert session.state.pending_confirmation == ""
assert all(item["status"] == "SUCCESS" for item in session.state.ip_states.values())
def test_chat_approve_then_resume_continues_after_failed_ip(tmp_path: Path):
fake = FakeActionRunner(
{
"verify-ip:192.168.1.10": {
"ACTION": "verify-ip",
"IP": "192.168.1.10",
"SUCCESS": "false",
"MESSAGE": "health check failed",
}
}
)
session = InteractiveCliSession(
agent=PamDeployAgent(fake_runner=fake),
params=PARAMS,
strategy="fake",
checkpoint_path=str(tmp_path / "checkpoint.json"),
)
run_session(session, ["run", "yes", "approve", "resume", "exit"])
assert session.state is not None
assert session.state.pending_confirmation == ""
assert session.state.ip_states["192.168.1.10"]["rollback_status"] == "ROLLBACK_DONE"
assert session.state.ip_states["192.168.1.11"]["status"] == "SUCCESS"

View File

@ -2,6 +2,7 @@ from dataclasses import asdict
from pam_deploy_graph.agent import PamDeployAgent from pam_deploy_graph.agent import PamDeployAgent
from pam_deploy_graph.checkpoint_store import redact_mapping from pam_deploy_graph.checkpoint_store import redact_mapping
from pam_deploy_graph.llm.openai_compatible import OpenAICompatibleLlmClient
from pam_deploy_graph.llm.rule_based import RuleBasedLlmClient from pam_deploy_graph.llm.rule_based import RuleBasedLlmClient
from pam_deploy_graph.llm.validators import validate_deploy_plan from pam_deploy_graph.llm.validators import validate_deploy_plan
from pam_deploy_graph.models import LlmDeployPlan from pam_deploy_graph.models import LlmDeployPlan
@ -71,3 +72,72 @@ def test_plan_guardrails_reject_executable_text():
assert "forbidden" in str(exc) assert "forbidden" in str(exc)
else: else:
raise AssertionError("expected guardrail failure") raise AssertionError("expected guardrail failure")
def test_openai_compatible_client_uses_base_url_api_key_and_prompt():
calls = []
def transport(url, headers, payload, timeout_sec):
calls.append((url, headers, payload, timeout_sec))
return {
"choices": [
{
"message": {
"content": (
'{"intent":"deploy","mode_preference":"MCP",'
'"strategy_preference":"hybrid_node_mcp","confidence":0.9,'
'"reasons":["ok"]}'
)
}
}
]
}
client = OpenAICompatibleLlmClient(
base_url="https://llm.example/v1",
api_key="secret-key",
model="model-a",
transport=transport,
)
result = client.understand_request("请用 MCP 部署")
assert result.intent == "deploy"
assert calls[0][0] == "https://llm.example/v1/chat/completions"
assert calls[0][1]["Authorization"] == "Bearer secret-key"
assert calls[0][2]["model"] == "model-a"
assert "只输出一个 JSON 对象" in calls[0][2]["messages"][0]["content"]
def test_openai_compatible_client_does_not_send_base_secret():
calls = []
def transport(url, headers, payload, timeout_sec):
calls.append(payload)
return {
"choices": [
{
"message": {
"content": (
'{"extracted_params":{"AIRPORT_CODE":"HET"},'
'"extracted_control":{},'
'"missing_required_params":[],'
'"ambiguous_fields":[]}'
)
}
}
]
}
client = OpenAICompatibleLlmClient(
base_url="https://llm.example/v1",
api_key="secret-key",
model="model-a",
transport=transport,
)
result = client.extract_params("机场 HET", {"CLIENT_SECRET": "real-secret", "CLIENT_ID": "id"})
serialized_prompt = str(calls[0])
assert "real-secret" not in serialized_prompt
assert result.extracted_params["CLIENT_SECRET"] == "real-secret"

View File

@ -1,5 +1,6 @@
from pam_deploy_graph.mcp_client import ( from pam_deploy_graph.mcp_client import (
FunctionMcpToolClient, FunctionMcpToolClient,
load_mcp_client_config,
SessionMcpToolClient, SessionMcpToolClient,
normalize_mcp_sdk_result, normalize_mcp_sdk_result,
) )
@ -26,3 +27,15 @@ def test_session_mcp_client_normalizes_text_json_content():
client = SessionMcpToolClient(Session()) client = SessionMcpToolClient(Session())
assert client.call_tool("tool", {}) == {"ok": True} assert client.call_tool("tool", {}) == {"ok": True}
def test_load_mcp_client_config(tmp_path):
path = tmp_path / "mcp.json"
path.write_text(
'{"server_name": "pam-node-prod", "tool_names": {"get-online-ips": "custom_ips"}}',
encoding="utf-8",
)
config = load_mcp_client_config(path)
assert config.server_name == "pam-node-prod"
assert config.tool_names["get-online-ips"] == "custom_ips"