优化 chat 交互链路并修复打包版参数传递问题

- 修复脚本配置文件路径处理问题,避免打包后 ZIP_FILE_PATH 等参数未生效并回退默认值
- 在 chat 模式执行前增加参数归一化和预检,提前检查 ZIP_FILE_PATH、脚本入口和 MCP 配置
- 优化 chat 交互体验,问候语不再触发结构化分析,分析前增加提示,执行中播报每步 action 状态
- 修复 action 失败被误判为 LangGraph 不可用的问题,失败后保留 checkpoint 并给出明确续跑提示
- 补齐 MCP 参数传递,支持向 action 传入 hashCode、nodeUrl、targetIp 等上下文
- 增强全局 action、单 IP action、回滚和日志下载的异常处理与进度回调
- 同步 README、打包 README 和 run.sh 帮助文案,更新打包后 chat 的实际使用说明
- 补充回归测试,覆盖 chat 预检、进度播报、问候处理、MCP 传参与配置路径修复
This commit is contained in:
dark 2026-06-03 09:48:36 +08:00
parent 6e694224ef
commit 5914e96693
10 changed files with 517 additions and 31 deletions

View File

@ -79,9 +79,11 @@ packaging/
- 本地已安装 `langgraph``mcp`,并完成 LangGraph fake 全局流程 smoke。
- CLI `analyze` 输出已做敏感字段脱敏。
- 增加 `chat` 常驻式 CLI 对话框支持自然语言分析、参数设置、执行确认、回滚确认、状态查看、事件查看、checkpoint 选择和续跑。
- chat 可选启用 `rich` / `prompt_toolkit`,支持更清晰输出、命令补全和输入历史。
- chat 在开发环境可选启用 `rich` / `prompt_toolkit`PyInstaller 打包环境默认使用普通文本输入,避免交互兼容问题。
- chat 执行前会归一化参数并展示实际写入脚本配置的值;`script_only` / `hybrid_node_mcp` 会提前检查 `ZIP_FILE_PATH` 是否存在。
- chat 执行中会播报每个 action 的开始、完成或失败action 执行失败会停在当前 checkpoint不再误报 LangGraph 不可用。
- 增加 action 后 LLM/规则诊断,可通过 `--analyze-actions``llm action-analysis on` 显式开启。
- 添加基础测试,当前本地结果为 `42 passed, 1 skipped`
- 添加基础测试,当前本地结果为 `51 passed, 2 skipped`。
未完成:
@ -253,6 +255,8 @@ PAM> preview
PAM> set VERSION_NUMBER=2.0.6
PAM> run
即将执行真实 action确认执行请输入 yes: yes
开始执行 action: get-token [backend=fake]
完成 action: get-token [backend=fake]
PAM> status
PAM> params
PAM> events 5
@ -265,7 +269,7 @@ PAM> resume
PAM> exit
```
`chat` 默认仍要求在会话内显式输入 `run`,并确认参数、目标 IP 范围和最终执行后才会执行 action如果某个 IP 失败,会通过 LangGraph interrupt 暂停并提示输入 `approve``reject [原因]`,确认后恢复同一个图线程继续执行。`chat` 也支持 `--llm-base-url` / `--llm-api-key` / `--llm-model``--mcp-config``--analyze-actions`
`chat` 默认仍要求在会话内显式输入 `run`,并确认参数、目标 IP 范围和最终执行后才会执行 action。输入 `你好``hello` 这类问候不会触发 LLM/结构化分析需要分析部署需求时可直接描述部署任务,或显式使用 `analyze <需求>`如果某个 IP 失败,会通过 LangGraph interrupt 暂停并提示输入 `approve``reject [原因]`,确认后恢复同一个图线程继续执行。`chat` 也支持 `--llm-base-url` / `--llm-api-key` / `--llm-model``--mcp-config``--analyze-actions`
预演:

View File

@ -32,8 +32,9 @@ pam-deploy-agent-linux-x86_64/
./run.sh run-deploy --help
```
发布包默认包含 `rich``prompt_toolkit`。如果终端支持chat 会自动启用更清晰的输出、命令补全和输入历史;不可用时会自动降级为普通文本输入输出
发布包默认使用普通文本输入,避免 PyInstaller 环境下 `prompt_toolkit` 兼容性问题;输出仍会在可用时使用 `rich` 做更清晰的文本展示
chat 内的失败回滚确认由 LangGraph interrupt 托管;执行停在确认点后,输入 `approve``reject [原因]` 会恢复同一个图线程继续处理。
chat 会在执行前归一化并展示实际写入脚本配置的参数;`script_only` / `hybrid_node_mcp` 会先检查 `ZIP_FILE_PATH` 是否存在,避免脚本运行后才用默认路径失败。执行过程中每个 action 都会输出开始、完成或失败状态。
## 交互式使用
@ -61,6 +62,8 @@ PAM> preview
PAM> set VERSION_NUMBER=2.0.6
PAM> run
即将执行真实 action确认执行请输入 yes: yes
开始执行 action: get-token [backend=fake]
完成 action: get-token [backend=fake]
PAM> status
PAM> params
PAM> events 5
@ -199,5 +202,6 @@ MCP token 获取方式与 HOME 一致,默认按 `client_credentials` POST 到
## 注意事项
- 执行真实 action 前请确认配置文件中的 `HOME_BASE_URL``CLIENT_ID``CLIENT_SECRET``AIRPORT_CODE``APP_NAME``MODULE_NAME``VERSION_NUMBER``ZIP_FILE_PATH`
- `chat` 中输入 `你好``hello` 这类问候不会触发 LLM/结构化分析;需要分析部署需求时请直接描述部署任务,或显式使用 `analyze <需求>`
- `checkpoint` 会保存完整运行参数,请放在受控目录。
- `hybrid_node_mcp``resume``confirm` 如果需要执行 MCP action请同时传入 `--mcp-config`

View File

@ -121,7 +121,7 @@ PAM 部署 Agent 解压即用包
--confirm
非交互命令执行真实 action 前必须显式传入。
chat 模式会在会话中要求输入 run 和 yes
chat 模式会在会话中要求输入 run,并分别确认参数、目标范围和最终执行
--analyze-actions
每个 action 完成后追加 LLM/规则诊断建议。诊断只作为辅助建议,
@ -164,8 +164,10 @@ LLM 环境变量:
2. doc_scripts 只包含运行必需文件deploy.sh、config.txt.example、PAM_AUTO_DEPLY_SKILL.md。
3. mcp_client.example.json 是 MCP server URL + 独立鉴权配置示例,需要按真实 MCP server 修改。
4. confirm 会通过 LangGraph interrupt resume 处理确认,并继续后续图节点;进程中断时再使用 resume。
5. chat 内可使用 params、events、list checkpoints、load checkpoint、llm config、mcp config 等命令。
6. checkpoint 会保存完整运行参数,请放在受控目录。
5. chat 会在执行前归一化并展示实际写入脚本配置的参数script_only / hybrid_node_mcp 会先检查 ZIP_FILE_PATH 是否存在。
6. chat 执行过程中会播报每个 action 的开始、完成或失败;普通问候不会触发 LLM/结构化分析。
7. chat 内可使用 params、events、list checkpoints、load checkpoint、llm config、mcp config 等命令。
8. checkpoint 会保存完整运行参数,请放在受控目录。
HELP_TEXT
}

View File

@ -45,7 +45,16 @@ class ActionRouter:
if backend == "mcp":
if self.mcp_runner is None:
raise RuntimeError(f"action 需要 MCP runner: {action}")
return self.mcp_runner.run(action, params=state.params, **kwargs)
mcp_kwargs = dict(kwargs)
hash_code = mcp_kwargs.pop("hash_code", None) or state.hash_code
node_url = mcp_kwargs.pop("node_url", None) or state.node_url
return self.mcp_runner.run(
action,
params=state.params,
hash_code=hash_code,
node_url=node_url,
**mcp_kwargs,
)
if self.fake_runner is None:
raise RuntimeError(f"action 需要 fake runner: {action}")
return self.fake_runner.run(action, params=state.params, **kwargs)

View File

@ -6,10 +6,11 @@
from __future__ import annotations
import re
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any
from typing import Any, Callable
from .action_router import ActionRouter, build_action_backends
from .checkpoint_store import save_checkpoint
@ -18,10 +19,15 @@ from .constants import DEFAULT_PARAMS, GLOBAL_ACTION_SEQUENCE, IP_ACTION_SEQUENC
from .fake_runner import FakeActionRunner
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 .models import ActionResult, AgentState, ExecutionStrategy, LlmDeployPlan, LlmIntentResult, LlmParamResult
from .script_runner import ScriptActionRunner, select_script_entry
from .skill_policy import load_skill_policy
REQUIRED_ACTION_VALUES = {
"upload-package": ("HASH_CODE",),
"get-node-url": ("NODE_URL",),
}
class PamDeployAgent:
"""PAM 部署主 Agent串联 LLM、action 路由、确认和续跑状态。"""
@ -35,6 +41,7 @@ class PamDeployAgent:
fake_runner: FakeActionRunner | None = None,
llm_client: LlmClient | None = None,
action_analysis_enabled: bool = False,
progress_callback: Callable[[dict[str, Any]], None] | None = None,
) -> None:
"""初始化策略、脚本 runner、MCP runner、fake runner 和 LLM client。"""
self.skill_policy = load_skill_policy(skill_path)
@ -44,6 +51,7 @@ class PamDeployAgent:
self.mcp_runner = mcp_runner
self.llm_client = llm_client or RuleBasedLlmClient()
self.action_analysis_enabled = action_analysis_enabled
self.progress_callback = progress_callback
self.router = ActionRouter(
script_runner=self.script_runner,
mcp_runner=mcp_runner,
@ -94,6 +102,7 @@ class PamDeployAgent:
missing = [key for key in REQUIRED_PARAMS if not normalized.get(key)]
if missing:
raise ValueError(f"缺少必填参数: {', '.join(missing)}")
normalized["ZIP_FILE_PATH"] = _normalize_local_file_path(str(normalized["ZIP_FILE_PATH"]).strip())
return normalized
def _choose_strategy(self, preference: str) -> ExecutionStrategy:
@ -119,8 +128,8 @@ class PamDeployAgent:
actual_run_id = run_id or time.strftime("%Y%m%d_%H%M%S")
actual_script_entry = script_entry or select_script_entry()
runtime_dir = Path("runtime")
actual_config_path = config_path or str(runtime_dir / f"config_{actual_run_id}.txt")
actual_trace_path = trace_file_path or str(Path("logs") / f"api_trace_{actual_run_id}.log")
actual_config_path = _absolute_path(config_path or runtime_dir / f"config_{actual_run_id}.txt")
actual_trace_path = _absolute_path(trace_file_path or Path("logs") / f"api_trace_{actual_run_id}.log")
write_config(normalized, actual_config_path)
return AgentState(
run_id=actual_run_id,
@ -129,8 +138,8 @@ class PamDeployAgent:
action_backends=build_action_backends(execution_strategy),
script_entry=actual_script_entry,
script_base_dir=str(self.script_base_dir),
config_path=actual_config_path,
trace_file_path=actual_trace_path,
config_path=str(actual_config_path),
trace_file_path=str(actual_trace_path),
checkpoint_path=checkpoint_path or "",
target_ips=target_ips or [],
)
@ -188,8 +197,22 @@ class PamDeployAgent:
return state
kwargs: dict[str, Any] = {}
if action == "publish-version":
if not state.hash_code:
state.last_failed_step = action
self._save_checkpoint(state)
raise RuntimeError("publish-version 缺少 HASH_CODE请确认 upload-package 是否成功返回 HASH_CODE")
kwargs["hash_code"] = state.hash_code
result = self.router.run_action(state, action, **kwargs)
backend = state.action_backends.get(action, "script")
self._emit_progress({"type": "ACTION_START", "stage": action, "backend": backend})
try:
result = self.router.run_action(state, action, **kwargs)
except Exception as exc:
result = ActionResult(
action=action,
backend=backend,
ok=False,
error_summary=str(exc),
)
state.events.append(
{
"type": "ACTION_DONE" if result.ok else "ACTION_FAIL",
@ -200,15 +223,50 @@ class PamDeployAgent:
)
self._append_action_analysis(state, action, result)
if not result.ok:
self._emit_progress(
{
"type": "ACTION_FAIL",
"stage": action,
"backend": result.backend,
"message": result.error_summary or "action 执行失败",
}
)
state.last_failed_step = action
self._save_checkpoint(state)
raise RuntimeError(f"{action} 执行失败: {result.error_summary}")
missing_values = self._missing_required_values(action, result.values)
if missing_values:
message = f"{action} 返回缺少必要字段: {', '.join(missing_values)}"
self._emit_progress(
{
"type": "ACTION_FAIL",
"stage": action,
"backend": result.backend,
"message": message,
}
)
state.last_failed_step = action
self._save_checkpoint(state)
raise RuntimeError(message)
self._apply_result(state, action, result.values)
state.completed_global_steps.append(action)
state.last_success_step = action
self._emit_progress(
{
"type": "ACTION_DONE",
"stage": action,
"backend": result.backend,
"message": result.values.get("MESSAGE", "ok"),
}
)
self._save_checkpoint(state)
return state
def _missing_required_values(self, action: str, values: dict[str, Any]) -> list[str]:
"""检查 action 成功返回后是否带回后续步骤必需字段。"""
required = REQUIRED_ACTION_VALUES.get(action, ())
return [key for key in required if not values.get(key)]
def run_deploy_flow(self, state: AgentState) -> AgentState:
"""执行完整部署流程:全局阶段后进入逐 IP 阶段。"""
if state.pending_confirmation:
@ -272,7 +330,24 @@ class PamDeployAgent:
completed_steps = ip_state.setdefault("completed_steps", [])
if action in completed_steps:
return state
result = self.router.run_action(state, action, ip=ip)
self._emit_progress(
{
"type": "ACTION_START",
"stage": action,
"backend": state.action_backends.get(action, ""),
"ip": ip,
}
)
backend = state.action_backends.get(action, "script")
try:
result = self.router.run_action(state, action, ip=ip)
except Exception as exc:
result = ActionResult(
action=action,
backend=backend,
ok=False,
error_summary=str(exc),
)
failed = (not result.ok) or self._business_failed(action, result.values)
state.events.append(
{
@ -286,6 +361,15 @@ class PamDeployAgent:
self._append_action_analysis(state, action, result, ip=ip)
if failed:
self._emit_progress(
{
"type": "ACTION_FAIL",
"stage": action,
"backend": result.backend,
"ip": ip,
"message": result.error_summary or result.values.get("MESSAGE", "action 执行失败"),
}
)
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)
@ -295,6 +379,15 @@ class PamDeployAgent:
self._apply_ip_result(ip_state, action, result.values)
completed_steps.append(action)
self._emit_progress(
{
"type": "ACTION_DONE",
"stage": action,
"backend": result.backend,
"ip": ip,
"message": result.values.get("MESSAGE", "ok"),
}
)
self._save_checkpoint(state)
return state
@ -343,12 +436,29 @@ class PamDeployAgent:
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)),
backend = state.action_backends.get("rollback-ip", "script")
self._emit_progress(
{
"type": "ACTION_START",
"stage": "rollback-ip",
"backend": backend,
"ip": ip,
}
)
try:
result = self.router.run_action(
state,
"rollback-ip",
ip=ip,
stop_first=bool(ip_state.get("rollback_stop_first", False)),
)
except Exception as exc:
result = ActionResult(
action="rollback-ip",
backend=backend,
ok=False,
error_summary=str(exc),
)
ip_state["rollback_status"] = "ROLLBACK_DONE" if result.ok else "ROLLBACK_FAILED"
state.events.append(
{
@ -364,12 +474,39 @@ class PamDeployAgent:
state.pending_confirmation = ""
state.last_success_step = "rollback-ip"
state.last_failed_step = ""
self._emit_progress(
{
"type": "ACTION_DONE",
"stage": "rollback-ip",
"backend": result.backend,
"ip": ip,
"message": result.values.get("MESSAGE", "ok"),
}
)
else:
state.pending_confirmation = f"rollback-ip:{ip}"
state.last_failed_step = "rollback-ip"
self._emit_progress(
{
"type": "ACTION_FAIL",
"stage": "rollback-ip",
"backend": result.backend,
"ip": ip,
"message": result.error_summary or result.values.get("MESSAGE", "rollback 执行失败"),
}
)
self._save_checkpoint(state)
return state
def _emit_progress(self, payload: dict[str, Any]) -> None:
"""向 CLI/chat 回调 action 执行进度,回调失败不影响主流程。"""
if self.progress_callback is None:
return
try:
self.progress_callback(payload)
except Exception:
return
def _apply_result(self, state: AgentState, action: str, values: dict[str, Any]) -> None:
"""把全局 action 返回值写回 AgentState。"""
if "HASH_CODE" in values:
@ -442,7 +579,25 @@ class PamDeployAgent:
def _download_log_best_effort(self, state: AgentState, ip: str) -> None:
"""失败后尽力下载日志,日志失败不覆盖原失败原因。"""
result = self.router.run_action(state, "download-log", ip=ip)
backend = state.action_backends.get("download-log", "script")
self._emit_progress(
{
"type": "ACTION_START",
"stage": "download-log",
"backend": backend,
"ip": ip,
"message": "失败后尝试下载日志",
}
)
try:
result = self.router.run_action(state, "download-log", ip=ip)
except Exception as exc:
result = ActionResult(
action="download-log",
backend=backend,
ok=False,
error_summary=str(exc),
)
ip_state = state.ip_states[ip]
if result.ok:
ip_state["log_file"] = str(result.values.get("LOG_FILE", ""))
@ -455,6 +610,15 @@ class PamDeployAgent:
"message": "已尽力下载日志",
}
)
self._emit_progress(
{
"type": "ACTION_DONE",
"stage": "download-log",
"backend": result.backend,
"ip": ip,
"message": result.values.get("MESSAGE", "已尽力下载日志"),
}
)
else:
state.events.append(
{
@ -465,6 +629,15 @@ class PamDeployAgent:
"message": result.error_summary or "尽力下载日志失败",
}
)
self._emit_progress(
{
"type": "ACTION_FAIL",
"stage": "download-log",
"backend": result.backend,
"ip": ip,
"message": result.error_summary or "尽力下载日志失败",
}
)
self._append_action_analysis(state, "download-log", result, ip=ip)
def _save_checkpoint(self, state: AgentState) -> None:
@ -552,3 +725,20 @@ class PamDeployAgent:
)
)
return "\n".join(lines)
def _absolute_path(path: str | Path) -> Path:
"""把传给脚本的文件路径转换为绝对路径,避免 cwd 切换后读错文件。"""
return Path(path).expanduser().resolve()
def _normalize_local_file_path(path: str) -> str:
"""把本地相对文件路径转换为绝对路径Windows/Unix 绝对路径保持不变。"""
if re.match(r"^[A-Za-z]:[\\/]", path):
return path
if path.startswith("/"):
return path
value = Path(path).expanduser()
if value.is_absolute():
return str(value)
return str(value.resolve())

View File

@ -75,6 +75,7 @@ class InteractiveCliSession:
self.llm_config: dict[str, str] = {}
self.mcp_config_path: str = ""
self.graph_runtime: LangGraphDeploymentRuntime | None = None
self.agent.progress_callback = self._on_progress
def run(self) -> None:
"""启动 REPL 循环,直到用户 exit 或输入流结束。"""
@ -151,6 +152,14 @@ class InteractiveCliSession:
self._load_checkpoint(rest.strip()[len("checkpoint") :].strip())
return True
if _is_small_talk(text):
self.output("你好。可以输入 help 查看命令,或直接描述部署需求;执行前仍需输入 run 并确认。")
return True
if not _looks_like_deploy_request(text):
self.output("我没有识别到明确的部署需求。可以输入 help 查看命令,或用 analyze <需求> 明确触发需求分析。")
return True
self.output("正在分析需求...")
self._analyze(text)
return True
@ -160,7 +169,11 @@ class InteractiveCliSession:
self.output("请输入要分析的自然语言需求例如analyze 请用 MCP 预演部署 HET。")
return
result = self.agent.analyze_request(text, self.params)
try:
result = self.agent.analyze_request(text, self.params)
except Exception as exc:
self.output(f"需求分析失败: {exc}")
return
self.last_analysis = result
param_result = result["params"]
intent_result = result["intent"]
@ -309,6 +322,17 @@ class InteractiveCliSession:
self._print_confirmation()
return
if not self._prepare_params_for_run():
return
problems = self._validate_run_prerequisites(self.params)
if problems:
self.output("执行前检查未通过:")
for problem in problems:
self.output(f"- {problem}")
self.output("请修正参数或配置后再输入 run。")
return
if not self._confirm_params_and_scope():
self.output("已取消执行。")
return
@ -356,18 +380,63 @@ class InteractiveCliSession:
if self.state is None:
self.output("当前没有运行状态。")
return
try:
if self.graph_runtime is None or not self.graph_runtime.waiting_confirmation:
if self.graph_runtime is None or not self.graph_runtime.waiting_confirmation:
try:
self.graph_runtime = LangGraphDeploymentRuntime(agent=self.agent)
except RuntimeError as exc:
self.output(f"LangGraph 确认运行器不可用,降级为本地执行: {exc}")
self.graph_runtime = None
try:
self.state = self.agent.run_deploy_flow(self.state)
except Exception as fallback_exc:
self._handle_execution_error(fallback_exc)
return
self._print_state_report_and_checkpoint()
return
try:
result = self.graph_runtime.start(self.state)
except RuntimeError as exc:
self.output(f"LangGraph 确认运行器不可用,降级为本地执行: {exc}")
self.graph_runtime = None
self.state = self.agent.run_deploy_flow(self.state)
self._print_state_report_and_checkpoint()
except Exception as exc:
self._handle_execution_error(exc)
return
self._apply_graph_result(result)
def _prepare_params_for_run(self) -> bool:
"""执行前归一化参数,确保确认值和实际写入脚本配置一致。"""
try:
self.params = self.agent.normalize_params(self.params)
except ValueError as exc:
self.output(f"参数检查失败: {exc}")
return False
return True
def _validate_run_prerequisites(self, params: dict[str, Any]) -> list[str]:
"""在创建 state 前检查可提前发现的运行问题。"""
problems: list[str] = []
if self.strategy != "fake":
zip_path = str(params.get("ZIP_FILE_PATH", "")).strip()
if not _path_exists(zip_path):
problems.append(f"ZIP_FILE_PATH 不存在: {zip_path}")
if self.strategy in ("script_only", "hybrid_node_mcp"):
script_entry = self.agent.script_base_dir / "deploy.sh"
ps_entry = self.agent.script_base_dir / "deploy.ps1"
if not script_entry.exists() and not ps_entry.exists():
problems.append(f"脚本入口不存在: {script_entry}{ps_entry}")
if self.strategy == "hybrid_node_mcp" and self.agent.mcp_runner is None:
problems.append("当前策略需要 MCP runner请启动时传 --mcp-config 或在 chat 内执行 mcp config <路径>。")
return problems
def _handle_execution_error(self, exc: Exception) -> None:
"""输出 action 执行失败后的可恢复提示,不再误报 LangGraph 不可用。"""
self.output(f"执行已停止: {exc}")
if self.state is None:
return
if self.state.last_failed_step:
self.output(f"最后失败步骤: {self.state.last_failed_step}")
if self.state.pending_confirmation:
self._print_confirmation()
self.output(f"checkpoint: {self.state.checkpoint_path or self.checkpoint_path}")
self.output("请修正参数或外部环境后,使用 load checkpoint <路径> / resume 继续,或重新 run。")
def _apply_graph_result(self, result: LangGraphRunResult) -> None:
"""把 LangGraph 运行结果同步回 chat 会话并输出用户可见状态。"""
if result.state is not None:
@ -429,6 +498,28 @@ class InteractiveCliSession:
if self.state.pending_confirmation:
self._print_confirmation()
def _on_progress(self, payload: dict[str, Any]) -> None:
"""把 Agent action 进度转成 chat 可见输出。"""
event_type = str(payload.get("type", ""))
stage = str(payload.get("stage", ""))
backend = str(payload.get("backend", ""))
ip = str(payload.get("ip", ""))
message = str(payload.get("message", ""))
suffix_parts = []
if backend:
suffix_parts.append(f"backend={backend}")
if ip:
suffix_parts.append(f"ip={ip}")
suffix = f" [{', '.join(suffix_parts)}]" if suffix_parts else ""
if event_type == "ACTION_START":
self.output(f"开始执行 action: {stage}{suffix}")
elif event_type == "ACTION_DONE":
detail = f": {message}" if message and message != "ok" else ""
self.output(f"完成 action: {stage}{suffix}{detail}")
elif event_type == "ACTION_FAIL":
detail = f": {message}" if message else ""
self.output(f"失败 action: {stage}{suffix}{detail}")
def _print_confirmation(self) -> None:
"""输出当前待人工确认事项。"""
if self.state is None:
@ -526,10 +617,68 @@ def _parse_key_values(parts: list[str]) -> dict[str, str]:
return values
def _is_small_talk(text: str) -> bool:
"""识别不应触发 LLM/结构化分析的简单寒暄。"""
normalized = text.strip().lower()
return normalized in {
"你好",
"您好",
"hello",
"hi",
"hey",
"在吗",
"谢谢",
"thanks",
"thank you",
}
def _looks_like_deploy_request(text: str) -> bool:
"""粗筛自然语言部署需求,避免任意闲聊都触发耗时分析。"""
lowered = text.lower()
deploy_keywords = (
"部署",
"发布",
"升级",
"回滚",
"预演",
"执行",
"pam",
"mcp",
"node",
"版本",
"机场",
"deploy",
"release",
"upgrade",
"rollback",
"preview",
)
param_markers = (
"HOME_BASE_URL",
"CLIENT_ID",
"AIRPORT_CODE",
"APP_NAME",
"MODULE_NAME",
"VERSION_NUMBER",
"ZIP_FILE_PATH",
)
return any(keyword in lowered for keyword in deploy_keywords) or any(marker in text for marker in param_markers)
def _path_exists(path: str) -> bool:
"""检查本地路径是否存在,兼容打包到 Linux 后的绝对路径。"""
if not path:
return False
return Path(path).expanduser().exists()
def _build_prompt_input(input_func: InputFunc) -> InputFunc:
"""如果安装了 prompt_toolkit则启用历史记录和命令补全。"""
if input_func is not builtins.input:
return input_func
if getattr(sys, "frozen", False):
return input_func
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter

View File

@ -54,6 +54,7 @@ class McpActionRunner:
params: dict[str, Any],
ip: str | None = None,
hash_code: str | None = None,
node_url: str | None = None,
stop_first: bool = False,
**_: Any,
) -> ActionResult:
@ -66,6 +67,7 @@ class McpActionRunner:
params=params,
ip=ip,
hash_code=hash_code,
node_url=node_url,
stop_first=stop_first,
)
try:
@ -116,6 +118,7 @@ class McpActionRunner:
params: dict[str, Any],
ip: str | None,
hash_code: str | None,
node_url: str | None,
stop_first: bool,
) -> dict[str, Any]:
"""把 Agent 参数转换为 MCP tool 所需的入参。"""
@ -133,6 +136,8 @@ class McpActionRunner:
arguments["targetIp"] = ip
if hash_code:
arguments["hashCode"] = hash_code
if node_url:
arguments["nodeUrl"] = node_url
if action == "rollback-ip":
arguments["stopFirst"] = stop_first
return {key: value for key, value in arguments.items() if value not in (None, "")}

View File

@ -1,5 +1,7 @@
from pathlib import Path
import pytest
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
@ -33,6 +35,41 @@ def test_run_deploy_flow_success(tmp_path: Path):
assert all(item["status"] == "SUCCESS" for item in state.ip_states.values())
def test_create_state_writes_absolute_script_config_path_and_normalized_zip(tmp_path: Path):
package_path = tmp_path / "pkg.zip"
params = {**PARAMS, "ZIP_FILE_PATH": str(package_path)}
agent = PamDeployAgent(fake_runner=FakeActionRunner())
state = agent.create_state(
params=params,
execution_strategy="fake",
config_path=str(tmp_path / "runtime" / "config.txt"),
trace_file_path=str(tmp_path / "logs" / "trace.log"),
)
assert Path(state.config_path).is_absolute()
assert Path(state.trace_file_path).is_absolute()
config_text = Path(state.config_path).read_text(encoding="utf-8")
assert f"ZIP_FILE_PATH={package_path.resolve()}" in config_text
def test_global_action_requires_hash_code_from_upload_package(tmp_path: Path):
fake = FakeActionRunner({"upload-package": {"ACTION": "upload-package"}})
agent = PamDeployAgent(fake_runner=fake)
state = agent.create_state(
params=PARAMS,
execution_strategy="fake",
config_path=str(tmp_path / "config.txt"),
checkpoint_path=str(tmp_path / "checkpoint.json"),
)
with pytest.raises(RuntimeError, match="缺少必要字段: HASH_CODE"):
agent.run_deploy_flow(state)
assert state.last_failed_step == "upload-package"
assert "upload-package" not in state.completed_global_steps
def test_run_deploy_flow_stops_on_verify_failure(tmp_path: Path):
fake = FakeActionRunner(
{

View File

@ -61,6 +61,69 @@ def test_chat_run_executes_fake_deploy_and_writes_checkpoint(tmp_path: Path):
assert all(item["status"] == "SUCCESS" for item in session.state.ip_states.values())
def test_chat_run_prints_action_progress(tmp_path: Path):
checkpoint = tmp_path / "checkpoint.json"
session = InteractiveCliSession(
agent=PamDeployAgent(fake_runner=FakeActionRunner()),
params=PARAMS,
strategy="fake",
checkpoint_path=str(checkpoint),
)
output = run_session(session, ["run", "yes", "yes", "yes", "exit"])
assert any("开始执行 action: get-token" in item for item in output)
assert any("完成 action: verify-ip" in item for item in output)
def test_chat_greeting_does_not_trigger_structured_analysis(tmp_path: Path):
session = InteractiveCliSession(
agent=PamDeployAgent(),
params=PARAMS,
strategy="fake",
checkpoint_path=str(tmp_path / "checkpoint.json"),
)
output = run_session(session, ["你好", "exit"])
assert session.last_analysis is None
assert any("可以输入 help 查看命令" in item for item in output)
assert not any("已生成结构化理解" in item for item in output)
def test_chat_preflight_blocks_missing_zip_path_before_confirm(tmp_path: Path):
missing_package = tmp_path / "missing.zip"
session = InteractiveCliSession(
agent=PamDeployAgent(),
params={**PARAMS, "ZIP_FILE_PATH": str(missing_package)},
strategy="script_only",
checkpoint_path=str(tmp_path / "checkpoint.json"),
)
output = run_session(session, ["run", "exit"])
assert session.state is None
assert any("执行前检查未通过" in item for item in output)
assert any("ZIP_FILE_PATH 不存在" in item for item in output)
def test_chat_action_failure_does_not_report_langgraph_unavailable(tmp_path: Path):
fake = FakeActionRunner({"upload-package": {"ACTION": "upload-package"}})
session = InteractiveCliSession(
agent=PamDeployAgent(fake_runner=fake),
params=PARAMS,
strategy="fake",
checkpoint_path=str(tmp_path / "checkpoint.json"),
)
output = run_session(session, ["run", "yes", "yes", "yes", "exit"])
assert session.state is not None
assert session.state.last_failed_step == "upload-package"
assert any("执行已停止" in item for item in output)
assert not any("LangGraph 确认运行器不可用" in item for item in output)
def test_chat_approve_then_resume_continues_after_failed_ip(tmp_path: Path):
fake = FakeActionRunner(
{
@ -143,4 +206,3 @@ def test_prompt_history_creates_runtime_dir(tmp_path: Path, monkeypatch):
assert callable(prompt)
assert (tmp_path / "runtime").is_dir()

View File

@ -191,6 +191,30 @@ def test_mcp_runner_auto_discovers_tool_name():
assert result.tool_name == "pam_get_online_ips"
def test_mcp_runner_passes_hash_code_and_node_url():
calls = []
class Client:
def call_tool(self, tool_name, arguments):
calls.append((tool_name, arguments))
return {"ACTION": "upgrade-ip", "SUCCESS": "true"}
runner = McpActionRunner(client=Client())
result = runner.run(
"upgrade-ip",
params={"HOME_BASE_URL": "https://pam.home", "AIRPORT_CODE": "HET"},
ip="192.168.1.10",
hash_code="hash-1",
node_url="https://pam.node",
)
assert result.ok is True
assert calls[0][1]["targetIp"] == "192.168.1.10"
assert calls[0][1]["hashCode"] == "hash-1"
assert calls[0][1]["nodeUrl"] == "https://pam.node"
def _write_json_config(tmpdir, payload):
path = tmpdir / "mcp.json"
path.write_text(__import__("json").dumps(payload), encoding="utf-8")