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 文件
checkpoint_store.py # 业务 checkpoint JSON 读写
params_loader.py # 读取 JSON 或 config.txt 风格参数文件
llm/ # LLM structured output 接口、规则 fallback 和 guardrails
llm/ # LLM structured output 接口、真实 HTTP client、提示词、规则 fallback 和 guardrails
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 入口
tests/
@ -37,6 +38,7 @@ tests/
test_params_loader.py
test_script_runner.py
test_skill_policy.py
test_interactive_cli.py
```
## 当前进度
@ -53,24 +55,111 @@ tests/
- 实现 fake 全局流程和完整部署流程,便于不触碰真实环境地验证 Agent 路由。
- 实现逐 IP 处理骨架:升级、轮询、启动、校验、日志下载。
- 实现单 IP 失败后的待回滚确认状态,不自动执行回滚。
- 实现人工确认入口:`confirm --decision approve|reject` 只处理待确认回滚。
- 实现 checkpoint 自动保存和 `resume` 续跑:全局步骤、成功 IP、单 IP 已完成 action 会跳过。
- 实现 LLM structured output 骨架:意图识别、参数抽取、部署计划生成。
- 实现 OpenAI-compatible 真实 LLM client支持 `base_url` / `api_key` / `model` 配置。
- 固化真实 LLM 提示词:意图识别、参数抽取、部署计划生成均要求 JSON structured output。
- 增加规则 fallback `RuleBasedLlmClient`,用于本地开发和测试。
- 增加 LLM 输出 guardrails禁止计划中出现可执行脚本命令和非法 action。
- 引入 `langgraph` 依赖,并提供 `build_langgraph()` 图工厂。
- 引入 MCP client adapter可包装 SDK session 或普通 callable。
- 引入 MCP client adapter可包装 SDK session 或普通 callable,并提供 JSON client 配置读取
- 本地已安装 `langgraph``mcp`,并完成 LangGraph fake 全局流程 smoke。
- CLI `analyze` 输出已做敏感字段脱敏。
- 添加基础测试,当前本地结果为 `22 passed, 1 skipped`
- 增加 `chat` 常驻式 CLI 对话框,支持自然语言分析、参数设置、执行确认、回滚确认、状态查看和续跑。
- 添加基础测试,当前本地结果为 `31 passed, 1 skipped`
未完成:
- 尚未接入真实 MCP client。
- 尚未接入真实 LLM 服务,目前使用规则 fallback。
- 尚未实现人工确认 interrupt、断点续跑完整图流程和单 IP 子流程。
- 尚未接入真实 MCP session当前已把 client adapter、tool 映射和配置格式准备好。
- 尚未执行真实脚本 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
@ -86,9 +175,24 @@ python -m pam_deploy_graph.cli run-global --config doc_scripts/config.txt.exampl
fake 完整部署流程验证:
```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
@ -104,9 +208,6 @@ pytest -q
## 下一步建议
1. 接入真实 PAM_NODE MCP session并用 `SessionMcpToolClient` 包装。
2. 用 fake runner 补齐完整部署主流程和单 IP 子流程测试。
3. 引入 LangGraph把当前 Agent 节点接入 `StateGraph`
4. 增加人工确认节点参数确认、IP 范围确认、回滚确认。
5. 接入真实 LLM 服务,实现 `RuleBasedLlmClient` 同协议替换。
6. 完善 checkpoint 恢复:全局步骤跳过、成功 IP 跳过、pending rollback 恢复。
7. 在测试环境中做 smokeHOME 脚本 `get-token/get-node-url` + NODE MCP `get-online-ips`
2. 在测试环境中做 smokeHOME 脚本 `get-token/get-node-url` + NODE MCP `get-online-ips`
3. 把当前 checkpoint/confirmation 语义继续接入 LangGraph interrupt/checkpointer。
4. 继续细化参数确认、IP 范围确认的交互式 UI 或上层编排。

View File

@ -11,10 +11,11 @@ from pathlib import Path
from typing import Any
from .action_router import ActionRouter, build_action_backends
from .checkpoint_store import save_checkpoint
from .config_writer import write_config
from .constants import DEFAULT_PARAMS, GLOBAL_ACTION_SEQUENCE, IP_ACTION_SEQUENCE, REQUIRED_PARAMS
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 .models import AgentState, ExecutionStrategy, LlmDeployPlan, LlmIntentResult, LlmParamResult
from .script_runner import ScriptActionRunner, select_script_entry
@ -29,7 +30,7 @@ class PamDeployAgent:
script_base_dir: str | Path = "doc_scripts",
mcp_runner: McpActionRunner | None = None,
fake_runner: FakeActionRunner | None = None,
llm_client: RuleBasedLlmClient | None = None,
llm_client: LlmClient | None = None,
) -> None:
self.skill_policy = load_skill_policy(skill_path)
self.script_base_dir = Path(script_base_dir)
@ -98,6 +99,7 @@ class PamDeployAgent:
script_entry: str | None = None,
config_path: str | None = None,
trace_file_path: str | None = None,
checkpoint_path: str | None = None,
target_ips: list[str] | None = None,
) -> AgentState:
normalized = self.normalize_params(params)
@ -116,6 +118,7 @@ class PamDeployAgent:
script_base_dir=str(self.script_base_dir),
config_path=actual_config_path,
trace_file_path=actual_trace_path,
checkpoint_path=checkpoint_path or "",
target_ips=target_ips or [],
)
@ -151,6 +154,8 @@ class PamDeployAgent:
def run_global_flow(self, state: AgentState) -> AgentState:
for action in GLOBAL_ACTION_SEQUENCE:
if action in state.completed_global_steps:
continue
kwargs: dict[str, Any] = {}
if action == "publish-version":
kwargs["hash_code"] = state.hash_code
@ -165,33 +170,54 @@ class PamDeployAgent:
)
if not result.ok:
state.last_failed_step = action
self._save_checkpoint(state)
raise RuntimeError(f"{action} failed: {result.error_summary}")
self._apply_result(state, action, result.values)
state.completed_global_steps.append(action)
state.last_success_step = action
self._save_checkpoint(state)
return state
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_ip_flow(state)
return state
def run_ip_flow(self, state: AgentState) -> AgentState:
if state.pending_confirmation:
self._save_checkpoint(state)
return state
self._resolve_target_ips(state)
for ip in state.target_ips:
state.events.append({"type": "IP_START", "ip": ip, "message": "start"})
ip_state = {
"status": "RUNNING",
"completed_steps": [],
"failed_stage": "",
"failure_reason": "",
"rollback_status": "ROLLBACK_NOT_RUN",
"rollback_stop_first": False,
"log_file": "",
}
state.ip_states[ip] = ip_state
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"})
ip_state = {
"status": "RUNNING",
"completed_steps": [],
"failed_stage": "",
"failure_reason": "",
"rollback_status": "ROLLBACK_NOT_RUN",
"rollback_stop_first": False,
"log_file": "",
}
state.ip_states[ip] = ip_state
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)
failed = (not result.ok) or self._business_failed(action, result.values)
state.events.append(
@ -209,13 +235,85 @@ class PamDeployAgent:
if action != "download-log":
self._download_log_best_effort(state, ip)
state.pending_confirmation = f"rollback-ip:{ip}"
self._save_checkpoint(state)
return state
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"
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
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:
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")

View File

@ -3,11 +3,12 @@
from __future__ import annotations
import json
from dataclasses import asdict, is_dataclass
from dataclasses import asdict, fields, is_dataclass
from pathlib import Path
from typing import Any
from .constants import SENSITIVE_KEYS
from .models import AgentState
def redact_mapping(value: Any) -> Any:
@ -24,12 +25,14 @@ def redact_mapping(value: Any) -> Any:
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.parent.mkdir(parents=True, exist_ok=True)
payload = asdict(state) if is_dataclass(state) else state
if redact:
payload = redact_mapping(payload)
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",
)
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]:
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 .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
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:
parser = argparse.ArgumentParser(prog="pam-deploy-agent")
sub = parser.add_subparsers(dest="command", required=True)
@ -22,21 +42,48 @@ def main() -> None:
analyze = sub.add_parser("analyze")
analyze.add_argument("--text", required=True)
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.add_argument("--config", required=True)
run.add_argument("--strategy", default="fake", choices=["hybrid_node_mcp", "script_only", "fake"])
run.add_argument("--checkpoint")
run.add_argument("--confirm", action="store_true")
deploy = sub.add_parser("run-deploy")
deploy.add_argument("--config", required=True)
deploy.add_argument("--strategy", default="fake", choices=["hybrid_node_mcp", "script_only", "fake"])
deploy.add_argument("--target-ip", action="append", default=[])
deploy.add_argument("--checkpoint")
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()
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":
result = agent.analyze_request(args.text, params)
@ -44,25 +91,61 @@ def main() -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2))
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":
print(agent.preview(params, args.strategy))
return
if not args.confirm:
raise SystemExit("Refusing to execute actions without --confirm.")
require_confirm(args)
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)
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
state = agent.create_state(
params=params,
execution_strategy=args.strategy,
checkpoint_path=args.checkpoint,
target_ips=args.target_ip,
)
state = agent.run_deploy_flow(state)
print(agent.render_report(state))
print_pause_payload(agent, state)
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."""
from .base import LlmClient
from .factory import build_llm_client
from .openai_compatible import OpenAICompatibleLlmClient
from .rule_based import RuleBasedLlmClient
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
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."""

View File

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

View File

@ -1,6 +1,8 @@
from pathlib import Path
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
@ -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 "192.168.1.11" not in state.ip_states
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.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.validators import validate_deploy_plan
from pam_deploy_graph.models import LlmDeployPlan
@ -71,3 +72,72 @@ def test_plan_guardrails_reject_executable_text():
assert "forbidden" in str(exc)
else:
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 (
FunctionMcpToolClient,
load_mcp_client_config,
SessionMcpToolClient,
normalize_mcp_sdk_result,
)
@ -26,3 +27,15 @@ def test_session_mcp_client_normalizes_text_json_content():
client = SessionMcpToolClient(Session())
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"