feat: 落地 PAM 智能部署 Agent 骨架
- 新增 pam_deploy_graph 包,包含 Agent runtime、ActionRouter、脚本/MCP/fake runner - 支持 hybrid_node_mcp 策略:PAM_HOME 走脚本 action,PAM_NODE 走 MCP - 支持 script_only 离线策略,全部 action 走现有脚本 action - 新增 LLM structured output 骨架和规则 fallback,支持意图识别、参数抽取、计划生成 - 新增 LangGraph StateGraph 工厂和 MCP client adapter - 新增 CLI:preview、analyze、run-global、run-deploy - 增加 fake 完整部署流程、单 IP 失败待回滚确认状态和报告输出 - 增加单元测试覆盖路由、parser、runner、Skill 加载、LLM 输出、MCP adapter 和 LangGraph 图 - 更新 README,记录当前代码骨架、进度、使用方式和下一步计划
This commit is contained in:
parent
ab7b839bc6
commit
14e297a488
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
runtime/
|
||||
logs/
|
||||
38
README.md
38
README.md
@ -10,7 +10,7 @@
|
||||
- PAM_NODE action 可通过 MCP runner 调用。
|
||||
- 默认执行策略为 `hybrid_node_mcp`,即 HOME 脚本 action + NODE MCP。
|
||||
- 离线策略为 `script_only`,全部 action 走脚本 action。
|
||||
- `langgraph` 当前作为可选依赖;本地未安装时,核心 Agent、runner、router 和 parser 仍可独立测试。
|
||||
- `langgraph` 已作为正式依赖引入;核心 Agent、runner、router 和 parser 也可独立测试。
|
||||
|
||||
## 当前代码骨架
|
||||
|
||||
@ -26,7 +26,9 @@ pam_deploy_graph/
|
||||
config_writer.py # 生成脚本 action 所需 config 文件
|
||||
checkpoint_store.py # 业务 checkpoint JSON 读写
|
||||
params_loader.py # 读取 JSON 或 config.txt 风格参数文件
|
||||
graph.py # 可选 LangGraph 集成入口
|
||||
llm/ # LLM structured output 接口、规则 fallback 和 guardrails
|
||||
graph.py # LangGraph StateGraph 集成入口
|
||||
mcp_client.py # MCP session/callable adapter
|
||||
cli.py # CLI 入口
|
||||
|
||||
tests/
|
||||
@ -48,14 +50,22 @@ tests/
|
||||
- 实现 MCP runner 抽象和 PAM_NODE action 到 MCP tool 的默认映射。
|
||||
- 实现脚本/MCP/fake action 结果统一为 `ActionResult`。
|
||||
- 实现 `config.txt.example` 风格和 JSON 风格参数读取。
|
||||
- 实现 fake 全局流程,便于不触碰真实环境地验证 Agent 路由。
|
||||
- 添加基础测试,当前 `10 passed`。
|
||||
- 实现 fake 全局流程和完整部署流程,便于不触碰真实环境地验证 Agent 路由。
|
||||
- 实现逐 IP 处理骨架:升级、轮询、启动、校验、日志下载。
|
||||
- 实现单 IP 失败后的待回滚确认状态,不自动执行回滚。
|
||||
- 实现 LLM structured output 骨架:意图识别、参数抽取、部署计划生成。
|
||||
- 增加规则 fallback `RuleBasedLlmClient`,用于本地开发和测试。
|
||||
- 增加 LLM 输出 guardrails,禁止计划中出现可执行脚本命令和非法 action。
|
||||
- 引入 `langgraph` 依赖,并提供 `build_langgraph()` 图工厂。
|
||||
- 引入 MCP client adapter,可包装 SDK session 或普通 callable。
|
||||
- 本地已安装 `langgraph` 和 `mcp`,并完成 LangGraph fake 全局流程 smoke。
|
||||
- CLI `analyze` 输出已做敏感字段脱敏。
|
||||
- 添加基础测试,当前本地结果为 `22 passed, 1 skipped`。
|
||||
|
||||
未完成:
|
||||
|
||||
- 尚未接入真实 MCP client。
|
||||
- 尚未安装并接入真实 LangGraph `StateGraph` 主图。
|
||||
- 尚未实现 LLM 结构化意图识别、参数抽取和计划生成。
|
||||
- 尚未接入真实 LLM 服务,目前使用规则 fallback。
|
||||
- 尚未实现人工确认 interrupt、断点续跑完整图流程和单 IP 子流程。
|
||||
- 尚未执行真实脚本 action 或真实 PAM_NODE MCP 调用。
|
||||
|
||||
@ -73,6 +83,18 @@ fake 全局流程验证:
|
||||
python -m pam_deploy_graph.cli run-global --config doc_scripts/config.txt.example --strategy fake --confirm
|
||||
```
|
||||
|
||||
fake 完整部署流程验证:
|
||||
|
||||
```bash
|
||||
python -m pam_deploy_graph.cli run-deploy --config doc_scripts/config.txt.example --strategy fake --confirm
|
||||
```
|
||||
|
||||
结构化理解和计划生成:
|
||||
|
||||
```bash
|
||||
python -m pam_deploy_graph.cli analyze --config doc_scripts/config.txt.example --text "请用 MCP 预演部署 HET PAM Node 版本 2.0.5,不要动环境"
|
||||
```
|
||||
|
||||
测试:
|
||||
|
||||
```bash
|
||||
@ -81,10 +103,10 @@ pytest -q
|
||||
|
||||
## 下一步建议
|
||||
|
||||
1. 接入真实 PAM_NODE MCP client,实现 `McpToolClient.call_tool()`。
|
||||
1. 接入真实 PAM_NODE MCP session,并用 `SessionMcpToolClient` 包装。
|
||||
2. 用 fake runner 补齐完整部署主流程和单 IP 子流程测试。
|
||||
3. 引入 LangGraph,把当前 Agent 节点接入 `StateGraph`。
|
||||
4. 增加人工确认节点:参数确认、IP 范围确认、回滚确认。
|
||||
5. 增加 LLM structured output:意图识别、参数抽取、部署计划、失败解释。
|
||||
5. 接入真实 LLM 服务,实现 `RuleBasedLlmClient` 同协议替换。
|
||||
6. 完善 checkpoint 恢复:全局步骤跳过、成功 IP 跳过、pending rollback 恢复。
|
||||
7. 在测试环境中做 smoke:HOME 脚本 `get-token/get-node-url` + NODE MCP `get-online-ips`。
|
||||
|
||||
@ -12,10 +12,11 @@ from typing import Any
|
||||
|
||||
from .action_router import ActionRouter, build_action_backends
|
||||
from .config_writer import write_config
|
||||
from .constants import DEFAULT_PARAMS, GLOBAL_ACTION_SEQUENCE, REQUIRED_PARAMS
|
||||
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 .mcp_runner import McpActionRunner
|
||||
from .models import AgentState, ExecutionStrategy
|
||||
from .models import AgentState, ExecutionStrategy, LlmDeployPlan, LlmIntentResult, LlmParamResult
|
||||
from .script_runner import ScriptActionRunner, select_script_entry
|
||||
from .skill_policy import load_skill_policy
|
||||
|
||||
@ -28,18 +29,54 @@ class PamDeployAgent:
|
||||
script_base_dir: str | Path = "doc_scripts",
|
||||
mcp_runner: McpActionRunner | None = None,
|
||||
fake_runner: FakeActionRunner | None = None,
|
||||
llm_client: RuleBasedLlmClient | None = None,
|
||||
) -> None:
|
||||
self.skill_policy = load_skill_policy(skill_path)
|
||||
self.script_base_dir = Path(script_base_dir)
|
||||
self.script_runner = ScriptActionRunner(self.script_base_dir)
|
||||
self.fake_runner = fake_runner or FakeActionRunner()
|
||||
self.mcp_runner = mcp_runner
|
||||
self.llm_client = llm_client or RuleBasedLlmClient()
|
||||
self.router = ActionRouter(
|
||||
script_runner=self.script_runner,
|
||||
mcp_runner=mcp_runner,
|
||||
fake_runner=self.fake_runner,
|
||||
)
|
||||
|
||||
def understand_request(self, text: str) -> LlmIntentResult:
|
||||
result = self.llm_client.understand_request(text)
|
||||
validate_intent_result(result)
|
||||
return result
|
||||
|
||||
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
|
||||
return self.llm_client.extract_params(text, base_params)
|
||||
|
||||
def generate_plan(
|
||||
self,
|
||||
*,
|
||||
params: dict[str, Any],
|
||||
intent: str,
|
||||
strategy: ExecutionStrategy,
|
||||
) -> LlmDeployPlan:
|
||||
plan = self.llm_client.generate_plan(params=params, intent=intent, strategy=strategy)
|
||||
validate_deploy_plan(plan)
|
||||
return plan
|
||||
|
||||
def analyze_request(self, text: str, base_params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
intent = self.understand_request(text)
|
||||
params = self.extract_params(text, base_params)
|
||||
strategy = self._choose_strategy(intent.strategy_preference)
|
||||
plan = self.generate_plan(
|
||||
params={**DEFAULT_PARAMS, **params.extracted_params},
|
||||
intent=intent.intent,
|
||||
strategy=strategy,
|
||||
)
|
||||
return {
|
||||
"intent": intent,
|
||||
"params": params,
|
||||
"plan": plan,
|
||||
}
|
||||
|
||||
def normalize_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = {**DEFAULT_PARAMS, **params}
|
||||
missing = [key for key in REQUIRED_PARAMS if not normalized.get(key)]
|
||||
@ -47,6 +84,11 @@ class PamDeployAgent:
|
||||
raise ValueError(f"Missing required params: {', '.join(missing)}")
|
||||
return normalized
|
||||
|
||||
def _choose_strategy(self, preference: str) -> ExecutionStrategy:
|
||||
if preference in ("hybrid_node_mcp", "script_only", "fake"):
|
||||
return preference # type: ignore[return-value]
|
||||
return "hybrid_node_mcp"
|
||||
|
||||
def create_state(
|
||||
self,
|
||||
*,
|
||||
@ -56,6 +98,7 @@ class PamDeployAgent:
|
||||
script_entry: str | None = None,
|
||||
config_path: str | None = None,
|
||||
trace_file_path: str | None = None,
|
||||
target_ips: list[str] | None = None,
|
||||
) -> AgentState:
|
||||
normalized = self.normalize_params(params)
|
||||
actual_run_id = run_id or time.strftime("%Y%m%d_%H%M%S")
|
||||
@ -73,6 +116,7 @@ class PamDeployAgent:
|
||||
script_base_dir=str(self.script_base_dir),
|
||||
config_path=actual_config_path,
|
||||
trace_file_path=actual_trace_path,
|
||||
target_ips=target_ips or [],
|
||||
)
|
||||
|
||||
def preview(self, params: dict[str, Any], strategy: ExecutionStrategy = "hybrid_node_mcp") -> str:
|
||||
@ -127,6 +171,53 @@ class PamDeployAgent:
|
||||
state.last_success_step = action
|
||||
return state
|
||||
|
||||
def run_deploy_flow(self, state: AgentState) -> AgentState:
|
||||
self.run_global_flow(state)
|
||||
self.run_ip_flow(state)
|
||||
return state
|
||||
|
||||
def run_ip_flow(self, state: AgentState) -> AgentState:
|
||||
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
|
||||
|
||||
for action in IP_ACTION_SEQUENCE:
|
||||
result = self.router.run_action(state, action, ip=ip)
|
||||
failed = (not result.ok) or self._business_failed(action, result.values)
|
||||
state.events.append(
|
||||
{
|
||||
"type": "ACTION_FAIL" if failed else "ACTION_DONE",
|
||||
"stage": action,
|
||||
"backend": result.backend,
|
||||
"ip": ip,
|
||||
"message": result.error_summary or result.values.get("MESSAGE", "ok"),
|
||||
}
|
||||
)
|
||||
|
||||
if failed:
|
||||
self._record_ip_failure(state, ip, action, result.error_summary or str(result.values))
|
||||
if action != "download-log":
|
||||
self._download_log_best_effort(state, ip)
|
||||
state.pending_confirmation = f"rollback-ip:{ip}"
|
||||
return state
|
||||
|
||||
self._apply_ip_result(ip_state, action, result.values)
|
||||
ip_state["completed_steps"].append(action)
|
||||
|
||||
ip_state["status"] = "SUCCESS"
|
||||
state.events.append({"type": "IP_DONE", "ip": ip, "message": "success"})
|
||||
return state
|
||||
|
||||
def _apply_result(self, state: AgentState, action: str, values: dict[str, Any]) -> None:
|
||||
if "HASH_CODE" in values:
|
||||
state.hash_code = str(values["HASH_CODE"])
|
||||
@ -138,3 +229,113 @@ class PamDeployAgent:
|
||||
ips = [ips]
|
||||
state.online_ips = list(ips)
|
||||
state.target_ips = state.target_ips or state.online_ips.copy()
|
||||
|
||||
def _resolve_target_ips(self, state: AgentState) -> None:
|
||||
if not state.target_ips:
|
||||
state.target_ips = state.online_ips.copy()
|
||||
return
|
||||
online = set(state.online_ips)
|
||||
requested = state.target_ips
|
||||
state.target_ips = [ip for ip in requested if ip in online]
|
||||
missing = [ip for ip in requested if ip not in online]
|
||||
if missing:
|
||||
state.events.append(
|
||||
{
|
||||
"type": "TARGET_SCOPE_CHANGED",
|
||||
"message": "some requested IPs are not online",
|
||||
"missing_ips": missing,
|
||||
"target_ips": state.target_ips,
|
||||
}
|
||||
)
|
||||
|
||||
def _business_failed(self, action: str, values: dict[str, Any]) -> bool:
|
||||
if action == "verify-ip":
|
||||
success = values.get("SUCCESS")
|
||||
if success is None:
|
||||
return False
|
||||
return str(success).lower() not in ("true", "1", "yes")
|
||||
return False
|
||||
|
||||
def _apply_ip_result(self, ip_state: dict[str, Any], action: str, values: dict[str, Any]) -> None:
|
||||
if action == "download-log":
|
||||
ip_state["log_file"] = str(values.get("LOG_FILE", ""))
|
||||
|
||||
def _record_ip_failure(self, state: AgentState, ip: str, action: str, reason: str) -> None:
|
||||
ip_state = state.ip_states[ip]
|
||||
stop_first = action in ("start-ip", "verify-ip")
|
||||
ip_state.update(
|
||||
{
|
||||
"status": "FAILED",
|
||||
"failed_stage": action,
|
||||
"failure_reason": reason,
|
||||
"rollback_status": "PENDING_AGENT_CONFIRMATION",
|
||||
"rollback_stop_first": stop_first,
|
||||
}
|
||||
)
|
||||
state.last_failed_step = action
|
||||
state.events.append(
|
||||
{
|
||||
"type": "CONFIRMATION_REQUIRED",
|
||||
"stage": "rollback-ip",
|
||||
"ip": ip,
|
||||
"stop_first": stop_first,
|
||||
"message": f"{action} failed; rollback confirmation required",
|
||||
}
|
||||
)
|
||||
|
||||
def _download_log_best_effort(self, state: AgentState, ip: str) -> None:
|
||||
result = self.router.run_action(state, "download-log", ip=ip)
|
||||
ip_state = state.ip_states[ip]
|
||||
if result.ok:
|
||||
ip_state["log_file"] = str(result.values.get("LOG_FILE", ""))
|
||||
state.events.append(
|
||||
{
|
||||
"type": "ACTION_DONE",
|
||||
"stage": "download-log",
|
||||
"backend": result.backend,
|
||||
"ip": ip,
|
||||
"message": "best effort log downloaded",
|
||||
}
|
||||
)
|
||||
else:
|
||||
state.events.append(
|
||||
{
|
||||
"type": "ACTION_FAIL",
|
||||
"stage": "download-log",
|
||||
"backend": result.backend,
|
||||
"ip": ip,
|
||||
"message": result.error_summary or "best effort log download failed",
|
||||
}
|
||||
)
|
||||
|
||||
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")
|
||||
lines = [
|
||||
"## PAM 智能部署报告",
|
||||
"",
|
||||
f"- 执行策略: {state.execution_strategy}",
|
||||
f"- 机场: {state.params['AIRPORT_CODE']}",
|
||||
f"- 应用: {state.params['APP_NAME']}",
|
||||
f"- 模块: {state.params['MODULE_NAME']}",
|
||||
f"- 版本: {state.params['VERSION_NUMBER']}",
|
||||
f"- 在线工作站数: {len(state.online_ips)}",
|
||||
f"- 目标工作站数: {len(state.target_ips)}",
|
||||
f"- 成功: {success}",
|
||||
f"- 失败: {failed}",
|
||||
f"- 待确认: {state.pending_confirmation or '-'}",
|
||||
"",
|
||||
"| IP | 状态 | 失败阶段 | 回滚状态 | 日志 |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for ip, ip_state in state.ip_states.items():
|
||||
lines.append(
|
||||
"| {ip} | {status} | {failed_stage} | {rollback_status} | {log_file} |".format(
|
||||
ip=ip,
|
||||
status=ip_state.get("status", ""),
|
||||
failed_stage=ip_state.get("failed_stage") or "-",
|
||||
rollback_status=ip_state.get("rollback_status") or "-",
|
||||
log_file=ip_state.get("log_file") or "-",
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
|
||||
from .agent import PamDeployAgent
|
||||
from .checkpoint_store import redact_mapping
|
||||
from .params_loader import load_params_file
|
||||
|
||||
|
||||
@ -17,24 +19,50 @@ def main() -> None:
|
||||
preview.add_argument("--config", required=True)
|
||||
preview.add_argument("--strategy", default="hybrid_node_mcp", choices=["hybrid_node_mcp", "script_only", "fake"])
|
||||
|
||||
analyze = sub.add_parser("analyze")
|
||||
analyze.add_argument("--text", required=True)
|
||||
analyze.add_argument("--config")
|
||||
|
||||
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("--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("--confirm", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
params = load_params_file(args.config)
|
||||
params = load_params_file(args.config) if getattr(args, "config", None) else {}
|
||||
agent = PamDeployAgent()
|
||||
|
||||
if args.command == "analyze":
|
||||
result = agent.analyze_request(args.text, params)
|
||||
payload = redact_mapping({key: asdict(value) for key, value in result.items()})
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
if args.command == "preview":
|
||||
print(agent.preview(params, args.strategy))
|
||||
return
|
||||
|
||||
if not args.confirm:
|
||||
raise SystemExit("Refusing to execute actions without --confirm.")
|
||||
if args.command == "run-global":
|
||||
state = agent.create_state(params=params, execution_strategy=args.strategy)
|
||||
state = agent.run_global_flow(state)
|
||||
print(json.dumps({"events": state.events}, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
state = agent.create_state(
|
||||
params=params,
|
||||
execution_strategy=args.strategy,
|
||||
target_ips=args.target_ip,
|
||||
)
|
||||
state = agent.run_deploy_flow(state)
|
||||
print(agent.render_report(state))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -14,7 +14,7 @@ class FakeActionRunner:
|
||||
|
||||
def run(self, action: str, *, params: dict[str, Any], **kwargs: Any) -> ActionResult:
|
||||
self.calls.append((action, kwargs))
|
||||
values = self.fixtures.get(action, {}).copy()
|
||||
values = self._fixture_for(action, kwargs)
|
||||
if not values:
|
||||
values = self._default_values(action, kwargs)
|
||||
ok = not values.pop("_fail", False)
|
||||
@ -38,7 +38,27 @@ class FakeActionRunner:
|
||||
return {"ACTION": action, "NODE_URL": "https://fake-node.local"}
|
||||
if action == "get-online-ips":
|
||||
return {"ACTION": action, "COUNT": "2", "IP": ["192.168.1.10", "192.168.1.11"]}
|
||||
if action == "upgrade-ip":
|
||||
return {"ACTION": action, "IP": kwargs.get("ip", ""), "RESULT": "TASK_CREATED"}
|
||||
if action == "poll-upgrade-progress":
|
||||
return {
|
||||
"ACTION": action,
|
||||
"IP": kwargs.get("ip", ""),
|
||||
"STEP": "DONE",
|
||||
"RATE_OF_PROGRESS": "100",
|
||||
"MESSAGE": "success",
|
||||
}
|
||||
if action == "start-ip":
|
||||
return {"ACTION": action, "IP": kwargs.get("ip", ""), "RESULT": "OK"}
|
||||
if action == "verify-ip":
|
||||
return {"ACTION": action, "IP": kwargs.get("ip", ""), "SUCCESS": "true", "MESSAGE": "ok"}
|
||||
if action == "download-log":
|
||||
return {"ACTION": action, "IP": kwargs.get("ip", ""), "LOG_FILE": "logs/fake.zip"}
|
||||
return {"ACTION": action, "RESULT": "OK"}
|
||||
|
||||
def _fixture_for(self, action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
ip = kwargs.get("ip")
|
||||
ip_key = f"{action}:{ip}" if ip else ""
|
||||
if ip_key and ip_key in self.fixtures:
|
||||
return self.fixtures[ip_key].copy()
|
||||
return self.fixtures.get(action, {}).copy()
|
||||
|
||||
@ -1,24 +1,67 @@
|
||||
"""Optional LangGraph integration.
|
||||
|
||||
The runtime works without LangGraph installed. This module exposes a factory for
|
||||
projects that install the optional dependency.
|
||||
"""
|
||||
"""LangGraph integration for the PAM deploy Agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
def build_langgraph():
|
||||
from .agent import PamDeployAgent
|
||||
|
||||
GraphFlow = Literal["global", "deploy"]
|
||||
|
||||
|
||||
def build_langgraph(agent: PamDeployAgent | None = None, flow: GraphFlow = "deploy"):
|
||||
try:
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
except ImportError as exc: # pragma: no cover - depends on optional package
|
||||
raise RuntimeError(
|
||||
"langgraph is not installed. Install the optional dependency with "
|
||||
"`pip install -e .[langgraph]`."
|
||||
"langgraph is not installed. Install project dependencies with "
|
||||
"`pip install -e .`."
|
||||
) from exc
|
||||
|
||||
runtime = agent or PamDeployAgent()
|
||||
|
||||
def create_state_node(state: dict[str, Any]) -> dict[str, Any]:
|
||||
agent_state = runtime.create_state(
|
||||
params=state["params"],
|
||||
execution_strategy=state.get("execution_strategy", "hybrid_node_mcp"),
|
||||
run_id=state.get("run_id"),
|
||||
script_entry=state.get("script_entry"),
|
||||
config_path=state.get("config_path"),
|
||||
trace_file_path=state.get("trace_file_path"),
|
||||
target_ips=state.get("target_ips"),
|
||||
)
|
||||
return {"agent_state": agent_state}
|
||||
|
||||
def run_global_node(state: dict[str, Any]) -> dict[str, Any]:
|
||||
agent_state = runtime.run_global_flow(state["agent_state"])
|
||||
return {"agent_state": agent_state}
|
||||
|
||||
def run_ip_node(state: dict[str, Any]) -> dict[str, Any]:
|
||||
agent_state = runtime.run_ip_flow(state["agent_state"])
|
||||
return {"agent_state": agent_state}
|
||||
|
||||
def report_node(state: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"report": runtime.render_report(state["agent_state"])}
|
||||
|
||||
graph = StateGraph(dict)
|
||||
graph.add_node("start", lambda state: state)
|
||||
graph.add_edge(START, "start")
|
||||
graph.add_edge("start", END)
|
||||
graph.add_node("create_state", create_state_node)
|
||||
graph.add_node("run_global", run_global_node)
|
||||
graph.add_node("run_ip", run_ip_node)
|
||||
graph.add_node("report", report_node)
|
||||
|
||||
graph.add_edge(START, "create_state")
|
||||
graph.add_edge("create_state", "run_global")
|
||||
if flow == "global":
|
||||
graph.add_edge("run_global", END)
|
||||
else:
|
||||
graph.add_edge("run_global", "run_ip")
|
||||
graph.add_edge("run_ip", "report")
|
||||
graph.add_edge("report", END)
|
||||
return graph.compile()
|
||||
|
||||
|
||||
def build_graph_or_none(agent: PamDeployAgent | None = None, flow: GraphFlow = "deploy"):
|
||||
try:
|
||||
return build_langgraph(agent=agent, flow=flow)
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
7
pam_deploy_graph/llm/__init__.py
Normal file
7
pam_deploy_graph/llm/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""LLM integration surfaces for PAM deploy Agent."""
|
||||
|
||||
from .rule_based import RuleBasedLlmClient
|
||||
from .validators import validate_deploy_plan, validate_intent_result
|
||||
|
||||
__all__ = ["RuleBasedLlmClient", "validate_deploy_plan", "validate_intent_result"]
|
||||
|
||||
167
pam_deploy_graph/llm/rule_based.py
Normal file
167
pam_deploy_graph/llm/rule_based.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""Deterministic fallback for LLM structured outputs.
|
||||
|
||||
This class is intentionally not a replacement for a real model. It gives the
|
||||
Agent stable structured outputs for local development and tests. A real LLM
|
||||
client should implement the same methods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pam_deploy_graph.constants import GLOBAL_ACTION_SEQUENCE, REQUIRED_PARAMS
|
||||
from pam_deploy_graph.models import (
|
||||
ExecutionStrategy,
|
||||
LlmDeployPlan,
|
||||
LlmIntentResult,
|
||||
LlmParamResult,
|
||||
)
|
||||
|
||||
KEY_ALIASES = {
|
||||
"home_base_url": "HOME_BASE_URL",
|
||||
"HOME_BASE_URL": "HOME_BASE_URL",
|
||||
"client_id": "CLIENT_ID",
|
||||
"CLIENT_ID": "CLIENT_ID",
|
||||
"client_secret": "CLIENT_SECRET",
|
||||
"CLIENT_SECRET": "CLIENT_SECRET",
|
||||
"airportCode": "AIRPORT_CODE",
|
||||
"AIRPORT_CODE": "AIRPORT_CODE",
|
||||
"applicationName": "APP_NAME",
|
||||
"APP_NAME": "APP_NAME",
|
||||
"moduleName": "MODULE_NAME",
|
||||
"MODULE_NAME": "MODULE_NAME",
|
||||
"versionNumber": "VERSION_NUMBER",
|
||||
"VERSION_NUMBER": "VERSION_NUMBER",
|
||||
"zipFilePath": "ZIP_FILE_PATH",
|
||||
"ZIP_FILE_PATH": "ZIP_FILE_PATH",
|
||||
"actionType": "ACTION_TYPE",
|
||||
"ACTION_TYPE": "ACTION_TYPE",
|
||||
"timeOut": "TIMEOUT",
|
||||
"TIMEOUT": "TIMEOUT",
|
||||
"logName": "LOG_NAME",
|
||||
"LOG_NAME": "LOG_NAME",
|
||||
}
|
||||
|
||||
|
||||
class RuleBasedLlmClient:
|
||||
def understand_request(self, text: str) -> LlmIntentResult:
|
||||
lowered = text.lower()
|
||||
reasons: list[str] = []
|
||||
intent = "deploy"
|
||||
|
||||
if any(word in lowered for word in ("用法", "怎么用", "生成脚本", "给我脚本", "usage")):
|
||||
intent = "show_usage"
|
||||
reasons.append("用户在询问脚本用法或脚本生成")
|
||||
elif any(word in lowered for word in ("预演", "计划", "不执行", "不要动环境", "dry-run", "preview")):
|
||||
intent = "preview"
|
||||
reasons.append("用户要求只预演或不触碰环境")
|
||||
elif any(word in lowered for word in ("在线ip", "在线 ip", "查询ip", "查询 ip", "node", "工作站")):
|
||||
intent = "query_node_ips"
|
||||
reasons.append("用户要求查询 Node 或在线工作站")
|
||||
elif any(word in lowered for word in ("回滚", "rollback")):
|
||||
intent = "rollback"
|
||||
reasons.append("用户要求回滚")
|
||||
else:
|
||||
reasons.append("默认识别为部署请求")
|
||||
|
||||
mode_preference = "未指定"
|
||||
strategy_preference = "未指定"
|
||||
if any(word in lowered for word in ("mcp", "在线执行", "直接在线")):
|
||||
mode_preference = "MCP"
|
||||
strategy_preference = "hybrid_node_mcp"
|
||||
reasons.append("用户倾向 MCP;PAM_HOME 仍需脚本 action")
|
||||
if any(word in lowered for word in ("脚本", "离线", "script", "shell", "powershell")):
|
||||
mode_preference = "API脚本"
|
||||
strategy_preference = "script_only"
|
||||
reasons.append("用户倾向脚本或离线执行")
|
||||
if intent == "preview":
|
||||
strategy_preference = strategy_preference if strategy_preference != "未指定" else "hybrid_node_mcp"
|
||||
|
||||
return LlmIntentResult(
|
||||
intent=intent, # type: ignore[arg-type]
|
||||
mode_preference=mode_preference, # type: ignore[arg-type]
|
||||
strategy_preference=strategy_preference, # type: ignore[arg-type]
|
||||
confidence=0.72 if intent != "deploy" else 0.6,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
|
||||
params = dict(base_params or {})
|
||||
params.update(self._extract_key_values(text))
|
||||
params.update(self._extract_chinese_patterns(text))
|
||||
|
||||
control: dict[str, Any] = {}
|
||||
ips = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", text)
|
||||
if ips:
|
||||
control["user_specified_ips"] = ips
|
||||
|
||||
missing = [key for key in REQUIRED_PARAMS if not params.get(key)]
|
||||
sensitive = [key for key in ("CLIENT_SECRET", "CLIENT_ID") if params.get(key)]
|
||||
return LlmParamResult(
|
||||
extracted_params=params,
|
||||
extracted_control=control,
|
||||
missing_required_params=missing,
|
||||
sensitive_fields_present=sensitive,
|
||||
)
|
||||
|
||||
def generate_plan(
|
||||
self,
|
||||
*,
|
||||
params: dict[str, Any],
|
||||
intent: str,
|
||||
strategy: ExecutionStrategy,
|
||||
) -> LlmDeployPlan:
|
||||
if strategy == "hybrid_node_mcp":
|
||||
strategy_text = "PAM_HOME 使用脚本 action,PAM_NODE 使用 MCP"
|
||||
elif strategy == "script_only":
|
||||
strategy_text = "全部 action 使用脚本 action"
|
||||
else:
|
||||
strategy_text = "全部 action 使用 fake runner"
|
||||
|
||||
summary = (
|
||||
f"计划处理 {params.get('AIRPORT_CODE', '-')}/"
|
||||
f"{params.get('APP_NAME', '-')}/"
|
||||
f"{params.get('MODULE_NAME', '-')}/"
|
||||
f"{params.get('VERSION_NUMBER', '-')},执行策略为 {strategy_text}。"
|
||||
)
|
||||
risk_notes = [
|
||||
"真实部署前必须确认参数。",
|
||||
"发布版本、创建下载任务、升级和回滚属于高风险动作。",
|
||||
"回滚只能在用户确认后执行。",
|
||||
]
|
||||
if strategy == "hybrid_node_mcp":
|
||||
risk_notes.append("PAM_HOME 当前没有 MCP 能力,HOME 阶段仍会调用脚本 action。")
|
||||
|
||||
return LlmDeployPlan(
|
||||
summary=summary,
|
||||
risk_notes=risk_notes,
|
||||
planned_actions=list(GLOBAL_ACTION_SEQUENCE),
|
||||
requires_confirmation=intent in ("deploy", "query_node_ips", "rollback"),
|
||||
execution_strategy=strategy,
|
||||
)
|
||||
|
||||
def _extract_key_values(self, text: str) -> dict[str, str]:
|
||||
params: dict[str, str] = {}
|
||||
for match in re.finditer(r"([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([^\s,;]+)", text):
|
||||
raw_key, value = match.groups()
|
||||
key = KEY_ALIASES.get(raw_key)
|
||||
if key:
|
||||
params[key] = value.strip()
|
||||
return params
|
||||
|
||||
def _extract_chinese_patterns(self, text: str) -> dict[str, str]:
|
||||
patterns = {
|
||||
"AIRPORT_CODE": r"(?:机场|三字码)\s*[::]?\s*([A-Z]{3})",
|
||||
"APP_NAME": r"(?:应用|应用名)\s*[::]?\s*([A-Za-z0-9_.-]+)",
|
||||
"MODULE_NAME": r"(?:模块|模块名)\s*[::]?\s*([A-Za-z0-9_.-]+)",
|
||||
"VERSION_NUMBER": r"(?:版本|版本号)\s*[::]?\s*([A-Za-z0-9_.-]+)",
|
||||
"ZIP_FILE_PATH": r"(?:包|软件包|zip)\s*[::]?\s*([A-Za-z]:[\\/][^\s,;]+|/[^\s,;]+)",
|
||||
}
|
||||
params: dict[str, str] = {}
|
||||
for key, pattern in patterns.items():
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
params[key] = match.group(1)
|
||||
return params
|
||||
|
||||
27
pam_deploy_graph/llm/validators.py
Normal file
27
pam_deploy_graph/llm/validators.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""Validation and guardrails for LLM structured outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pam_deploy_graph.constants import ALLOWED_ACTIONS
|
||||
from pam_deploy_graph.models import LlmDeployPlan, LlmIntentResult
|
||||
|
||||
VALID_INTENTS = {"deploy", "show_usage", "preview", "query_node_ips", "rollback"}
|
||||
FORBIDDEN_TEXT = ("bash ", "powershell ", "deploy.sh", "deploy.ps1", "CLIENT_SECRET=")
|
||||
|
||||
|
||||
def validate_intent_result(result: LlmIntentResult) -> None:
|
||||
if result.intent not in VALID_INTENTS:
|
||||
raise ValueError(f"Invalid intent: {result.intent}")
|
||||
if not 0 <= result.confidence <= 1:
|
||||
raise ValueError("Intent confidence must be between 0 and 1")
|
||||
|
||||
|
||||
def validate_deploy_plan(plan: LlmDeployPlan) -> None:
|
||||
invalid = [action for action in plan.planned_actions if action not in ALLOWED_ACTIONS]
|
||||
if invalid:
|
||||
raise ValueError(f"Plan contains invalid actions: {', '.join(invalid)}")
|
||||
combined_text = "\n".join([plan.summary, *plan.risk_notes])
|
||||
lowered = combined_text.lower()
|
||||
forbidden = [item for item in FORBIDDEN_TEXT if item.lower() in lowered]
|
||||
if forbidden:
|
||||
raise ValueError(f"Plan contains forbidden executable text: {', '.join(forbidden)}")
|
||||
65
pam_deploy_graph/mcp_client.py
Normal file
65
pam_deploy_graph/mcp_client.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""MCP client adapters.
|
||||
|
||||
The Agent only needs a synchronous `call_tool(name, arguments)` surface. This
|
||||
module adapts simple callables or SDK-like sessions to that surface without
|
||||
forcing the rest of the codebase to import a concrete MCP SDK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
class FunctionMcpToolClient:
|
||||
"""Wrap a plain Python callable as an MCP tool client."""
|
||||
|
||||
def __init__(self, caller: Callable[[str, dict[str, Any]], Any]) -> None:
|
||||
self.caller = caller
|
||||
|
||||
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
||||
return self.caller(tool_name, arguments)
|
||||
|
||||
|
||||
class SessionMcpToolClient:
|
||||
"""Adapt SDK-like sessions exposing `call_tool`.
|
||||
|
||||
The adapter accepts common result shapes:
|
||||
|
||||
- raw dict/list/string
|
||||
- object with `structuredContent`
|
||||
- object with `content`, where text content may contain JSON
|
||||
"""
|
||||
|
||||
def __init__(self, session: Any) -> None:
|
||||
if not hasattr(session, "call_tool"):
|
||||
raise TypeError("MCP session must expose call_tool")
|
||||
self.session = session
|
||||
|
||||
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
||||
result = self.session.call_tool(tool_name, arguments)
|
||||
return normalize_mcp_sdk_result(result)
|
||||
|
||||
|
||||
def normalize_mcp_sdk_result(result: Any) -> Any:
|
||||
if hasattr(result, "structuredContent"):
|
||||
structured = getattr(result, "structuredContent")
|
||||
if structured is not None:
|
||||
return structured
|
||||
|
||||
if hasattr(result, "content"):
|
||||
content = getattr(result, "content")
|
||||
text_parts: list[str] = []
|
||||
for item in content or []:
|
||||
text = getattr(item, "text", None)
|
||||
if text is not None:
|
||||
text_parts.append(text)
|
||||
if text_parts:
|
||||
joined = "\n".join(text_parts)
|
||||
try:
|
||||
return json.loads(joined)
|
||||
except json.JSONDecodeError:
|
||||
return joined
|
||||
|
||||
return result
|
||||
@ -7,6 +7,9 @@ from typing import Any, Literal
|
||||
|
||||
BackendName = Literal["mcp", "script", "fake"]
|
||||
ExecutionStrategy = Literal["hybrid_node_mcp", "script_only", "fake"]
|
||||
IntentName = Literal["deploy", "show_usage", "preview", "query_node_ips", "rollback"]
|
||||
ModePreference = Literal["MCP", "API脚本", "未指定"]
|
||||
StrategyPreference = Literal["hybrid_node_mcp", "script_only", "fake", "未指定"]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@ -46,6 +49,35 @@ class SkillPolicy:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LlmIntentResult:
|
||||
intent: IntentName
|
||||
mode_preference: ModePreference = "未指定"
|
||||
strategy_preference: StrategyPreference = "未指定"
|
||||
confidence: float = 0.0
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
needs_clarification: bool = False
|
||||
clarification_questions: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LlmParamResult:
|
||||
extracted_params: dict[str, Any] = field(default_factory=dict)
|
||||
extracted_control: dict[str, Any] = field(default_factory=dict)
|
||||
missing_required_params: list[str] = field(default_factory=list)
|
||||
ambiguous_fields: list[str] = field(default_factory=list)
|
||||
sensitive_fields_present: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LlmDeployPlan:
|
||||
summary: str
|
||||
risk_notes: list[str] = field(default_factory=list)
|
||||
planned_actions: list[str] = field(default_factory=list)
|
||||
requires_confirmation: bool = True
|
||||
execution_strategy: StrategyPreference = "未指定"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentState:
|
||||
run_id: str
|
||||
@ -68,4 +100,3 @@ class AgentState:
|
||||
last_success_step: str = ""
|
||||
last_failed_step: str = ""
|
||||
events: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@ -3,13 +3,17 @@ name = "pam-deploy-graph"
|
||||
version = "0.1.0"
|
||||
description = "LangGraph-style PAM deploy agent with Skill policy, mixed HOME script actions, and NODE MCP routing."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"langgraph>=0.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
langgraph = ["langgraph"]
|
||||
mcp = ["mcp>=1"]
|
||||
test = ["pytest"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["pam_deploy_graph*"]
|
||||
|
||||
58
tests/test_agent_flow.py
Normal file
58
tests/test_agent_flow.py
Normal file
@ -0,0 +1,58 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pam_deploy_graph.agent import PamDeployAgent
|
||||
from pam_deploy_graph.fake_runner import FakeActionRunner
|
||||
|
||||
|
||||
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 test_run_deploy_flow_success(tmp_path: Path):
|
||||
agent = PamDeployAgent(fake_runner=FakeActionRunner())
|
||||
state = agent.create_state(
|
||||
params=PARAMS,
|
||||
execution_strategy="fake",
|
||||
config_path=str(tmp_path / "config.txt"),
|
||||
)
|
||||
|
||||
agent.run_deploy_flow(state)
|
||||
|
||||
assert state.pending_confirmation == ""
|
||||
assert set(state.ip_states) == {"192.168.1.10", "192.168.1.11"}
|
||||
assert all(item["status"] == "SUCCESS" for item in state.ip_states.values())
|
||||
|
||||
|
||||
def test_run_deploy_flow_stops_on_verify_failure(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)
|
||||
|
||||
assert state.pending_confirmation == "rollback-ip:192.168.1.10"
|
||||
assert state.ip_states["192.168.1.10"]["status"] == "FAILED"
|
||||
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)
|
||||
37
tests/test_graph.py
Normal file
37
tests/test_graph.py
Normal file
@ -0,0 +1,37 @@
|
||||
import importlib.util
|
||||
|
||||
import pytest
|
||||
|
||||
from pam_deploy_graph.graph import build_graph_or_none, build_langgraph
|
||||
from pam_deploy_graph.params_loader import load_params_file
|
||||
|
||||
|
||||
def test_build_graph_or_none_without_langgraph_is_safe():
|
||||
graph = build_graph_or_none()
|
||||
if importlib.util.find_spec("langgraph"):
|
||||
assert graph is not None
|
||||
else:
|
||||
assert graph is None
|
||||
|
||||
|
||||
def test_build_langgraph_error_without_dependency_is_clear():
|
||||
if importlib.util.find_spec("langgraph"):
|
||||
pytest.skip("langgraph installed")
|
||||
with pytest.raises(RuntimeError, match="langgraph is not installed"):
|
||||
build_langgraph()
|
||||
|
||||
|
||||
def test_langgraph_invokes_global_flow_when_installed(tmp_path):
|
||||
if not importlib.util.find_spec("langgraph"):
|
||||
pytest.skip("langgraph not installed")
|
||||
graph = build_langgraph(flow="global")
|
||||
result = graph.invoke(
|
||||
{
|
||||
"params": load_params_file("doc_scripts/config.txt.example"),
|
||||
"execution_strategy": "fake",
|
||||
"config_path": str(tmp_path / "config.txt"),
|
||||
}
|
||||
)
|
||||
state = result["agent_state"]
|
||||
assert state.completed_global_steps[-1] == "poll-download-progress"
|
||||
assert state.action_backends["get-online-ips"] == "fake"
|
||||
73
tests/test_llm_structured.py
Normal file
73
tests/test_llm_structured.py
Normal file
@ -0,0 +1,73 @@
|
||||
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.rule_based import RuleBasedLlmClient
|
||||
from pam_deploy_graph.llm.validators import validate_deploy_plan
|
||||
from pam_deploy_graph.models import LlmDeployPlan
|
||||
|
||||
|
||||
def test_understand_request_prefers_hybrid_for_mcp():
|
||||
result = RuleBasedLlmClient().understand_request("请用 MCP 部署 HET")
|
||||
assert result.intent == "deploy"
|
||||
assert result.mode_preference == "MCP"
|
||||
assert result.strategy_preference == "hybrid_node_mcp"
|
||||
|
||||
|
||||
def test_extract_params_from_key_value_text():
|
||||
result = RuleBasedLlmClient().extract_params(
|
||||
"HOME_BASE_URL=https://x CLIENT_ID=id CLIENT_SECRET=s AIRPORT_CODE=HET "
|
||||
"APP_NAME=PAM MODULE_NAME=Node VERSION_NUMBER=2.0.5 ZIP_FILE_PATH=C:/pkg.zip"
|
||||
)
|
||||
assert result.extracted_params["AIRPORT_CODE"] == "HET"
|
||||
assert result.missing_required_params == []
|
||||
assert "CLIENT_SECRET" in result.sensitive_fields_present
|
||||
|
||||
|
||||
def test_analyze_request_returns_structured_objects():
|
||||
agent = PamDeployAgent()
|
||||
result = agent.analyze_request(
|
||||
"不要动环境,预演部署",
|
||||
{
|
||||
"HOME_BASE_URL": "https://x",
|
||||
"CLIENT_ID": "id",
|
||||
"CLIENT_SECRET": "s",
|
||||
"AIRPORT_CODE": "HET",
|
||||
"APP_NAME": "PAM",
|
||||
"MODULE_NAME": "Node",
|
||||
"VERSION_NUMBER": "2.0.5",
|
||||
"ZIP_FILE_PATH": "C:/pkg.zip",
|
||||
},
|
||||
)
|
||||
payload = {key: asdict(value) for key, value in result.items()}
|
||||
assert payload["intent"]["intent"] == "preview"
|
||||
assert payload["plan"]["execution_strategy"] == "hybrid_node_mcp"
|
||||
|
||||
|
||||
def test_analyze_payload_can_be_redacted():
|
||||
agent = PamDeployAgent()
|
||||
result = agent.analyze_request(
|
||||
"帮我部署",
|
||||
{
|
||||
"HOME_BASE_URL": "https://x",
|
||||
"CLIENT_ID": "id",
|
||||
"CLIENT_SECRET": "super-secret",
|
||||
"AIRPORT_CODE": "HET",
|
||||
"APP_NAME": "PAM",
|
||||
"MODULE_NAME": "Node",
|
||||
"VERSION_NUMBER": "2.0.5",
|
||||
"ZIP_FILE_PATH": "C:/pkg.zip",
|
||||
},
|
||||
)
|
||||
payload = redact_mapping({key: asdict(value) for key, value in result.items()})
|
||||
assert payload["params"]["extracted_params"]["CLIENT_SECRET"] == "***"
|
||||
|
||||
|
||||
def test_plan_guardrails_reject_executable_text():
|
||||
plan = LlmDeployPlan(summary="run bash ./deploy.sh", planned_actions=["get-token"])
|
||||
try:
|
||||
validate_deploy_plan(plan)
|
||||
except ValueError as exc:
|
||||
assert "forbidden" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected guardrail failure")
|
||||
28
tests/test_mcp_client.py
Normal file
28
tests/test_mcp_client.py
Normal file
@ -0,0 +1,28 @@
|
||||
from pam_deploy_graph.mcp_client import (
|
||||
FunctionMcpToolClient,
|
||||
SessionMcpToolClient,
|
||||
normalize_mcp_sdk_result,
|
||||
)
|
||||
|
||||
|
||||
def test_function_mcp_client_wraps_callable():
|
||||
client = FunctionMcpToolClient(lambda name, args: {"tool": name, "args": args})
|
||||
assert client.call_tool("pam_get_online_ips", {"airportCode": "HET"})["tool"] == "pam_get_online_ips"
|
||||
|
||||
|
||||
def test_normalize_mcp_sdk_result_structured_content():
|
||||
result = type("Result", (), {"structuredContent": {"ok": True}})()
|
||||
assert normalize_mcp_sdk_result(result) == {"ok": True}
|
||||
|
||||
|
||||
def test_session_mcp_client_normalizes_text_json_content():
|
||||
content = [type("Text", (), {"text": '{"ok": true}'})()]
|
||||
result = type("Result", (), {"content": content})()
|
||||
|
||||
class Session:
|
||||
def call_tool(self, tool_name, arguments):
|
||||
return result
|
||||
|
||||
client = SessionMcpToolClient(Session())
|
||||
assert client.call_tool("tool", {}) == {"ok": True}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user