docs/build: 补齐中文注释、流程图和 Linux 解压即用打包脚本

- 为 pam_deploy_graph 生产代码补充中文模块、类、函数/方法文档字符串
- 将原有英文说明和主要英文异常提示改为中文
- 新增当前整体逻辑结构流程图文档,覆盖模块结构、执行链路、action 路由、人工确认和 checkpoint 续跑
- 新增 Linux 自带运行环境打包脚本,使用 PyInstaller 生成解压即用目录和 tar.gz
- 新增 Linux 打包说明,包含构建命令、运行方式、依赖说明和包大小评估
- 同步 README,补充流程图、打包方式、产物路径和大小预估
- 更新相关测试断言以匹配中文错误提示
This commit is contained in:
dark 2026-06-01 11:21:42 +08:00
parent 1e74ae3cd6
commit a11904b7c5
31 changed files with 587 additions and 98 deletions

View File

@ -39,6 +39,13 @@ tests/
test_script_runner.py
test_skill_policy.py
test_interactive_cli.py
docs/
current_logic_flow.md # 当前整体逻辑结构流程图
packaging/
build_linux_self_contained.sh # Linux 解压即用包构建脚本
README_linux_package.md # Linux 打包说明和包大小评估
```
## 当前进度
@ -138,6 +145,36 @@ agent = PamDeployAgent(mcp_runner=runner)
## 使用方式
整体逻辑结构流程图:
```text
docs/current_logic_flow.md
```
Linux 解压即用打包:
```bash
bash packaging/build_linux_self_contained.sh
```
构建产物会输出到:
```text
dist/linux_self_contained/pam-deploy-agent-linux-x86_64/
dist/linux_self_contained/pam-deploy-agent-linux-x86_64.tar.gz
```
目标机器解压后运行:
```bash
tar -xzf pam-deploy-agent-linux-x86_64.tar.gz
cd pam-deploy-agent-linux-x86_64
./run.sh --help
./run.sh chat --config doc_scripts/config.txt.example --strategy fake --checkpoint runtime/checkpoints/demo.json
```
包大小以构建脚本末尾打印的 `du` 结果为准。按当前依赖估算:默认包含 MCP 依赖时压缩包约 60-110 MB、解压后约 160-300 MB使用 `PACKAGE_EXTRAS=` 构建最小包时压缩包约 45-75 MB、解压后约 120-200 MB。
交互式对话框:
```bash

137
docs/current_logic_flow.md Normal file
View File

@ -0,0 +1,137 @@
# 当前整体逻辑结构流程图
本文描述当前 PAM 部署 Agent 的主要模块、运行路径、人工确认点和断点续跑逻辑。
## 模块结构
```mermaid
flowchart TD
U[用户/上层系统] --> CLI[cli.py 命令行入口]
U --> CHAT[interactive.py 交互式 chat]
CLI --> PARAMS[params_loader.py 读取参数]
CHAT --> PARAMS
CLI --> LLMF[llm.factory 构造 LLM client]
CHAT --> LLMF
LLMF --> RULE[RuleBasedLlmClient 规则 fallback]
LLMF --> REAL[OpenAICompatibleLlmClient 真实 LLM]
REAL --> PROMPTS[llm.prompts 结构化提示词]
CLI --> AGENT[PamDeployAgent]
CHAT --> AGENT
PARAMS --> AGENT
RULE --> AGENT
REAL --> AGENT
AGENT --> ROUTER[ActionRouter]
ROUTER --> SCRIPT[ScriptActionRunner]
ROUTER --> MCP[McpActionRunner]
ROUTER --> FAKE[FakeActionRunner]
SCRIPT --> DEPLOY[doc_scripts/deploy.sh 或 deploy.ps1]
MCP --> MCPCLIENT[mcp_client.py: Session/Function adapter]
FAKE --> FIXTURE[测试 fixture 或默认 fake 返回值]
AGENT --> CHECKPOINT[checkpoint_store.py]
AGENT --> REPORT[render_report 部署报告]
```
## analyze/chat 理解和计划链路
```mermaid
flowchart TD
A[用户输入自然语言] --> B[understand_request 识别意图]
B --> C[validate_intent_result 校验意图]
C --> D[extract_params 抽取参数和控制信息]
D --> E[选择执行策略]
E --> F[generate_plan 生成部署计划]
F --> G[validate_deploy_plan 校验 action 和危险文本]
G --> H[输出结构化理解/计划]
H --> I{用户是否执行}
I -- 否 --> X[仅预演或继续对话]
I -- 是 --> J[run / yes 后进入部署执行]
```
## 部署执行主流程
```mermaid
flowchart TD
A[create_state 创建运行状态] --> B[normalize_params 合并默认参数并校验必填项]
B --> C[write_config 写脚本配置文件]
C --> D[build_action_backends 生成 action 路由表]
D --> E[run_deploy_flow]
E --> F{是否存在 pending_confirmation}
F -- 是 --> P[暂停并保存 checkpoint]
F -- 否 --> G[run_global_flow 全局阶段]
G --> G1[get-token]
G1 --> G2[create-version]
G2 --> G3[upload-package]
G3 --> G4[publish-version]
G4 --> G5[get-node-url]
G5 --> G6[get-online-ips]
G6 --> G7[create-download-task]
G7 --> G8[poll-download-progress]
G8 --> H[run_ip_flow 逐 IP 阶段]
H --> I[resolve_target_ips 计算目标 IP]
I --> J[upgrade-ip]
J --> K[poll-upgrade-progress]
K --> L[start-ip]
L --> M[verify-ip]
M --> N[download-log]
N --> O{还有下一个 IP}
O -- 是 --> J
O -- 否 --> R[render_report 输出报告]
```
## action 路由规则
```mermaid
flowchart LR
A[action] --> B{execution_strategy}
B -- fake --> F[fake runner 执行所有 action]
B -- script_only --> S[脚本执行所有 action]
B -- hybrid_node_mcp --> C{action 类型}
C -- PAM_HOME action --> HS[脚本执行]
C -- PAM_NODE action --> NM[MCP tool 执行]
```
## 失败、人工确认和续跑
```mermaid
flowchart TD
A[逐 IP action 执行] --> B{action 失败或业务校验失败}
B -- 否 --> C[记录 completed_steps 并保存 checkpoint]
B -- 是 --> D[记录 ip_state 为 FAILED]
D --> E[download-log 尽力下载日志]
E --> F[设置 pending_confirmation=rollback-ip:IP]
F --> G[保存 checkpoint 并暂停]
G --> H{用户决定}
H -- approve --> I[confirm_pending 执行 rollback-ip]
I --> J{rollback 是否成功}
J -- 是 --> K[清空 pending_confirmation]
J -- 否 --> L[保持 pending_confirmation等待再次处理]
H -- reject --> M[标记 REJECTED_BY_OPERATOR 并清空 pending_confirmation]
K --> N[resume 续跑]
M --> N
N --> O[跳过已完成全局步骤、成功 IP 和单 IP 已完成 action]
```
## checkpoint 续跑语义
- `completed_global_steps`:全局阶段已经完成的 action 会跳过。
- `ip_states[ip].status == SUCCESS`:成功 IP 会跳过。
- `ip_states[ip].completed_steps`:同一个 IP 已完成的 action 会跳过。
- `pending_confirmation`:存在待确认事项时,部署流程不继续执行,必须先 `approve``reject`
- checkpoint 为了真实续跑会保存完整参数,请放在受控目录中。
## 真实外部能力接入点
- 真实 LLM`llm.openai_compatible.OpenAICompatibleLlmClient`,通过 `PAM_LLM_BASE_URL``PAM_LLM_API_KEY``PAM_LLM_MODEL` 或 CLI 参数配置。
- 真实 MCP外部建立 MCP session 后,用 `SessionMcpToolClient` 包装,再传给 `McpActionRunner`
- 真实脚本PAM_HOME action 通过 `doc_scripts/deploy.sh``deploy.ps1` 调用。

View File

@ -0,0 +1,55 @@
# Linux 解压即用打包说明
## 目标
`build_linux_self_contained.sh` 会在 Linux x86_64 构建机上生成一个自带 Python 运行时和 Python 依赖的发布包。目标机器解压后可以直接运行,不需要额外安装 Python 或 pip 依赖。
## 构建机要求
- Linux x86_64
- `bash`
- `python3`,需要带 `venv` 模块
- 构建时需要访问 Python 包索引,用于安装 PyInstaller 和项目依赖
该脚本不支持在 Windows 上交叉构建 Linux 包。建议在和目标环境 glibc 版本相同或更旧的 Linux 发行版上构建,以提高二进制兼容性。
## 构建命令
```bash
bash packaging/build_linux_self_contained.sh
```
默认会安装 `.[mcp]`,即包含 MCP 可选依赖。如果只想打最小包:
```bash
PACKAGE_EXTRAS= bash packaging/build_linux_self_contained.sh
```
构建产物:
```text
dist/linux_self_contained/pam-deploy-agent-linux-x86_64/
dist/linux_self_contained/pam-deploy-agent-linux-x86_64.tar.gz
```
## 解压后运行
```bash
tar -xzf pam-deploy-agent-linux-x86_64.tar.gz
cd pam-deploy-agent-linux-x86_64
./run.sh --help
./run.sh chat --config doc_scripts/config.txt.example --strategy fake --checkpoint runtime/checkpoints/demo.json
```
`run.sh` 会切换到发布目录再启动可执行程序,因此默认的 `doc_scripts/...` 相对路径可以正常工作。
## 包大小评估
最终大小以脚本末尾打印的 `du` 结果为准。按当前依赖结构预估:
| 构建方式 | 压缩包大小 | 解压后大小 | 主要影响因素 |
| --- | ---: | ---: | --- |
| 默认包含 MCP 依赖 | 60-110 MB | 160-300 MB | PyInstaller 运行时、langgraph、mcp 及其依赖 |
| `PACKAGE_EXTRAS=` 最小包 | 45-75 MB | 120-200 MB | PyInstaller 运行时、langgraph 及基础依赖 |
当前项目源码和脚本文档本身体积很小,包大小主要由 Python 运行时、PyInstaller bootloader、第三方依赖和动态库决定。

View File

@ -0,0 +1,117 @@
#!/usr/bin/env bash
# 构建 Linux 解压即用包:包含 Python 运行时、依赖、CLI 可执行程序和脚本文档。
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PYTHON_BIN="${PYTHON_BIN:-python3}"
APP_NAME="pam-deploy-agent"
RELEASE_NAME="${APP_NAME}-linux-x86_64"
PACKAGE_EXTRAS="${PACKAGE_EXTRAS:-mcp}"
BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build/linux_self_contained}"
DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist/linux_self_contained}"
RELEASE_DIR="$DIST_DIR/$RELEASE_NAME"
ARCHIVE_PATH="$DIST_DIR/${RELEASE_NAME}.tar.gz"
if [[ "$(uname -s)" != "Linux" ]]; then
echo "该脚本需要在 Linux x86_64 构建机上运行。"
exit 1
fi
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "未找到 Python: $PYTHON_BIN"
exit 1
fi
echo "==> 清理旧构建目录"
rm -rf "$BUILD_DIR" "$RELEASE_DIR" "$ARCHIVE_PATH"
mkdir -p "$BUILD_DIR" "$DIST_DIR"
echo "==> 创建构建虚拟环境"
"$PYTHON_BIN" -m venv "$BUILD_DIR/venv"
source "$BUILD_DIR/venv/bin/activate"
echo "==> 安装构建依赖"
python -m pip install --upgrade pip setuptools wheel
python -m pip install pyinstaller
echo "==> 安装项目依赖"
if [[ -n "$PACKAGE_EXTRAS" ]]; then
python -m pip install -e ".[${PACKAGE_EXTRAS}]"
else
python -m pip install -e .
fi
echo "==> 使用 PyInstaller 生成自带 Python 运行时的可执行目录"
python -m PyInstaller \
--clean \
--noconfirm \
--name "$APP_NAME" \
--onedir \
--console \
--distpath "$BUILD_DIR/pyinstaller_dist" \
--workpath "$BUILD_DIR/pyinstaller_build" \
--specpath "$BUILD_DIR" \
--collect-submodules pam_deploy_graph \
--collect-submodules langgraph \
--hidden-import pam_deploy_graph.cli \
packaging/pyinstaller_entry.py
echo "==> 组装发布目录"
mkdir -p "$RELEASE_DIR"
cp -a "$BUILD_DIR/pyinstaller_dist/$APP_NAME/." "$RELEASE_DIR/"
cp -a doc_scripts "$RELEASE_DIR/doc_scripts"
cp -a README.md "$RELEASE_DIR/README.md"
cp -a LICENSE "$RELEASE_DIR/LICENSE"
cat > "$RELEASE_DIR/run.sh" <<'RUN_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
exec "$DIR/pam-deploy-agent" "$@"
RUN_SCRIPT
chmod +x "$RELEASE_DIR/run.sh"
cat > "$RELEASE_DIR/使用说明.txt" <<'USAGE_TEXT'
PAM 部署 Agent Linux 解压即用包
使用方式:
./run.sh --help
./run.sh chat --config doc_scripts/config.txt.example --strategy fake --checkpoint runtime/checkpoints/demo.json
./run.sh analyze --config doc_scripts/config.txt.example --text "请用 MCP 预演部署 HET PAM Node 版本 2.0.5,不要动环境"
说明:
1. 该包已包含 Python 运行时和 Python 依赖,目标机器不需要额外安装 Python 包。
2. 真实 LLM 仍需通过 PAM_LLM_BASE_URL、PAM_LLM_API_KEY、PAM_LLM_MODEL 或 CLI 参数配置。
3. 真实 MCP session 仍需由你在外部接入后传给 Agent当前包已包含 MCP client adapter。
4. checkpoint 会保存完整运行参数,请放在受控目录。
USAGE_TEXT
echo "==> 生成 tar.gz"
tar -C "$DIST_DIR" -czf "$ARCHIVE_PATH" "$RELEASE_NAME"
format_bytes() {
local bytes="$1"
python - "$bytes" <<'PY'
import sys
value = float(sys.argv[1])
units = ["B", "KB", "MB", "GB"]
for unit in units:
if value < 1024 or unit == units[-1]:
print(f"{value:.1f} {unit}")
break
value /= 1024
PY
}
EXTRACTED_BYTES="$(du -sb "$RELEASE_DIR" | awk '{print $1}')"
ARCHIVE_BYTES="$(du -sb "$ARCHIVE_PATH" | awk '{print $1}')"
echo "==> 构建完成"
echo "发布目录: $RELEASE_DIR"
echo "压缩包: $ARCHIVE_PATH"
echo "解压后大小: $(format_bytes "$EXTRACTED_BYTES")"
echo "压缩包大小: $(format_bytes "$ARCHIVE_BYTES")"

View File

@ -0,0 +1,7 @@
"""PyInstaller 打包入口。"""
from pam_deploy_graph.cli import main
if __name__ == "__main__":
main()

View File

@ -1,6 +1,5 @@
"""PAM deploy agent package."""
"""PAM 部署 Agent 包入口。"""
from .agent import PamDeployAgent
__all__ = ["PamDeployAgent"]

View File

@ -1,4 +1,4 @@
"""Action routing for HOME script actions and NODE MCP actions."""
"""按照执行策略把 action 路由到脚本、MCP 或 fake runner。"""
from __future__ import annotations
@ -7,6 +7,7 @@ from .models import AgentState, BackendName, ExecutionStrategy, ActionResult
def build_action_backends(strategy: ExecutionStrategy) -> dict[str, BackendName]:
"""根据执行策略生成每个 action 对应的后端类型。"""
if strategy == "fake":
return {action: "fake" for action in ALLOWED_ACTIONS}
if strategy == "script_only":
@ -15,19 +16,23 @@ def build_action_backends(strategy: ExecutionStrategy) -> dict[str, BackendName]
routes: dict[str, BackendName] = {action: "script" for action in HOME_ACTIONS}
routes.update({action: "mcp" for action in NODE_ACTIONS})
return routes
raise ValueError(f"Unknown execution strategy: {strategy}")
raise ValueError(f"未知执行策略: {strategy}")
class ActionRouter:
"""统一的 action 调度器屏蔽脚本、MCP 和 fake 后端差异。"""
def __init__(self, *, script_runner, mcp_runner=None, fake_runner=None) -> None:
"""保存各类 runner运行时按 state 中的路由表选择后端。"""
self.script_runner = script_runner
self.mcp_runner = mcp_runner
self.fake_runner = fake_runner
def run_action(self, state: AgentState, action: str, **kwargs) -> ActionResult:
"""执行一个 action并返回统一的 ActionResult。"""
backend = state.action_backends.get(action)
if not backend:
raise ValueError(f"Action is not routed: {action}")
raise ValueError(f"action 未配置路由: {action}")
if backend == "script":
return self.script_runner.run(
action,
@ -39,9 +44,8 @@ class ActionRouter:
)
if backend == "mcp":
if self.mcp_runner is None:
raise RuntimeError(f"MCP runner is required for action: {action}")
raise RuntimeError(f"action 需要 MCP runner: {action}")
return self.mcp_runner.run(action, params=state.params, **kwargs)
if self.fake_runner is None:
raise RuntimeError(f"Fake runner is required for action: {action}")
raise RuntimeError(f"action 需要 fake runner: {action}")
return self.fake_runner.run(action, params=state.params, **kwargs)

View File

@ -1,7 +1,7 @@
"""PAM deploy Agent runtime.
"""PAM 部署 Agent 运行时。
This is intentionally runnable without langgraph installed. The same nodes can
be wired into LangGraph later via pam_deploy_graph.graph.
本模块不强依赖 LangGraph可独立运行同一组节点也可在
`pam_deploy_graph.graph` 中接入 LangGraph
"""
from __future__ import annotations
@ -23,6 +23,8 @@ from .skill_policy import load_skill_policy
class PamDeployAgent:
"""PAM 部署主 Agent串联 LLM、action 路由、确认和续跑状态。"""
def __init__(
self,
*,
@ -32,6 +34,7 @@ class PamDeployAgent:
fake_runner: FakeActionRunner | None = None,
llm_client: LlmClient | None = None,
) -> None:
"""初始化策略、脚本 runner、MCP runner、fake runner 和 LLM client。"""
self.skill_policy = load_skill_policy(skill_path)
self.script_base_dir = Path(script_base_dir)
self.script_runner = ScriptActionRunner(self.script_base_dir)
@ -45,11 +48,13 @@ class PamDeployAgent:
)
def understand_request(self, text: str) -> LlmIntentResult:
"""调用 LLM 识别用户意图,并执行基础校验。"""
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(
@ -59,11 +64,13 @@ class PamDeployAgent:
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]:
"""完成意图识别、参数抽取和计划生成,供 analyze/chat 使用。"""
intent = self.understand_request(text)
params = self.extract_params(text, base_params)
strategy = self._choose_strategy(intent.strategy_preference)
@ -79,13 +86,15 @@ class PamDeployAgent:
}
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)]
if missing:
raise ValueError(f"Missing required params: {', '.join(missing)}")
raise ValueError(f"缺少必填参数: {', '.join(missing)}")
return normalized
def _choose_strategy(self, preference: str) -> ExecutionStrategy:
"""把 LLM 给出的策略偏好转换为内部执行策略。"""
if preference in ("hybrid_node_mcp", "script_only", "fake"):
return preference # type: ignore[return-value]
return "hybrid_node_mcp"
@ -102,6 +111,7 @@ class PamDeployAgent:
checkpoint_path: str | None = None,
target_ips: list[str] | None = None,
) -> AgentState:
"""创建一次运行所需的 AgentState并写入脚本配置文件。"""
normalized = self.normalize_params(params)
actual_run_id = run_id or time.strftime("%Y%m%d_%H%M%S")
actual_script_entry = script_entry or select_script_entry()
@ -123,6 +133,7 @@ class PamDeployAgent:
)
def preview(self, params: dict[str, Any], strategy: ExecutionStrategy = "hybrid_node_mcp") -> str:
"""渲染部署预演,展示参数和 action 路由。"""
normalized = self.normalize_params(params)
routes = build_action_backends(strategy)
if strategy == "hybrid_node_mcp":
@ -153,6 +164,7 @@ class PamDeployAgent:
return "\n".join(lines)
def run_global_flow(self, state: AgentState) -> AgentState:
"""执行全局部署阶段,并跳过 checkpoint 中已完成的步骤。"""
for action in GLOBAL_ACTION_SEQUENCE:
if action in state.completed_global_steps:
continue
@ -171,7 +183,7 @@ class PamDeployAgent:
if not result.ok:
state.last_failed_step = action
self._save_checkpoint(state)
raise RuntimeError(f"{action} failed: {result.error_summary}")
raise RuntimeError(f"{action} 执行失败: {result.error_summary}")
self._apply_result(state, action, result.values)
state.completed_global_steps.append(action)
state.last_success_step = action
@ -179,6 +191,7 @@ class PamDeployAgent:
return state
def run_deploy_flow(self, state: AgentState) -> AgentState:
"""执行完整部署流程:全局阶段后进入逐 IP 阶段。"""
if state.pending_confirmation:
self._save_checkpoint(state)
return state
@ -187,6 +200,7 @@ class PamDeployAgent:
return state
def run_ip_flow(self, state: AgentState) -> AgentState:
"""执行逐 IP 部署流程,失败时停在人工确认点。"""
if state.pending_confirmation:
self._save_checkpoint(state)
return state
@ -248,6 +262,7 @@ class PamDeployAgent:
return state
def build_confirmation_request(self, state: AgentState) -> dict[str, Any]:
"""把 pending_confirmation 转换为面向用户的确认请求。"""
if not state.pending_confirmation:
return {}
kind, _, value = state.pending_confirmation.partition(":")
@ -268,11 +283,12 @@ class PamDeployAgent:
}
def confirm_pending(self, state: AgentState, *, approved: bool, operator_note: str = "") -> AgentState:
"""处理人工确认结果;当前支持失败 IP 的回滚确认。"""
request = self.build_confirmation_request(state)
if not request:
raise ValueError("No pending confirmation")
raise ValueError("当前没有待确认事项")
if request["type"] != "rollback-ip":
raise ValueError(f"Unsupported confirmation type: {request['type']}")
raise ValueError(f"不支持的确认类型: {request['type']}")
ip = request["ip"]
ip_state = state.ip_states[ip]
@ -317,6 +333,7 @@ class PamDeployAgent:
return state
def _apply_result(self, state: AgentState, action: str, values: dict[str, Any]) -> None:
"""把全局 action 返回值写回 AgentState。"""
if "HASH_CODE" in values:
state.hash_code = str(values["HASH_CODE"])
if "NODE_URL" in values:
@ -329,6 +346,7 @@ class PamDeployAgent:
state.target_ips = state.target_ips or state.online_ips.copy()
def _resolve_target_ips(self, state: AgentState) -> None:
"""根据在线 IP 和用户指定 IP 计算最终目标 IP。"""
if not state.target_ips:
state.target_ips = state.online_ips.copy()
return
@ -347,6 +365,7 @@ class PamDeployAgent:
)
def _business_failed(self, action: str, values: dict[str, Any]) -> bool:
"""识别 exit code 之外的业务失败条件。"""
if action == "verify-ip":
success = values.get("SUCCESS")
if success is None:
@ -355,10 +374,12 @@ class PamDeployAgent:
return False
def _apply_ip_result(self, ip_state: dict[str, Any], action: str, values: dict[str, Any]) -> None:
"""把逐 IP action 返回值写回单 IP 状态。"""
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 失败,并设置待回滚确认状态。"""
ip_state = state.ip_states[ip]
stop_first = action in ("start-ip", "verify-ip")
ip_state.update(
@ -377,11 +398,12 @@ class PamDeployAgent:
"stage": "rollback-ip",
"ip": ip,
"stop_first": stop_first,
"message": f"{action} failed; rollback confirmation required",
"message": f"{action} 执行失败,需要确认是否回滚",
}
)
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:
@ -392,7 +414,7 @@ class PamDeployAgent:
"stage": "download-log",
"backend": result.backend,
"ip": ip,
"message": "best effort log downloaded",
"message": "已尽力下载日志",
}
)
else:
@ -402,15 +424,17 @@ class PamDeployAgent:
"stage": "download-log",
"backend": result.backend,
"ip": ip,
"message": result.error_summary or "best effort log download failed",
"message": result.error_summary or "尽力下载日志失败",
}
)
def _save_checkpoint(self, state: AgentState) -> None:
"""如果配置了 checkpoint 路径,则保存完整运行状态。"""
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")
lines = [

View File

@ -1,4 +1,4 @@
"""Business checkpoint JSON storage."""
"""业务 checkpoint 的 JSON 存储与恢复工具。"""
from __future__ import annotations
@ -12,6 +12,7 @@ from .models import AgentState
def redact_mapping(value: Any) -> Any:
"""递归脱敏 dict/list 中的敏感字段。"""
if isinstance(value, dict):
result = {}
for key, item in value.items():
@ -26,6 +27,7 @@ def redact_mapping(value: Any) -> Any:
def save_checkpoint(state: Any, path: str | Path, *, redact: bool = True) -> Path:
"""保存 checkpoint真实续跑场景可关闭脱敏以保留必要参数。"""
checkpoint_path = Path(path)
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
payload = asdict(state) if is_dataclass(state) else state
@ -39,14 +41,17 @@ def save_checkpoint(state: Any, path: str | Path, *, redact: bool = True) -> Pat
def load_checkpoint(path: str | Path) -> dict[str, Any]:
"""从 JSON 文件读取原始 checkpoint 字典。"""
return json.loads(Path(path).read_text(encoding="utf-8"))
def agent_state_from_mapping(payload: dict[str, Any]) -> AgentState:
"""把 checkpoint 字典转换回 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:
"""读取 checkpoint 文件并恢复 AgentState。"""
return agent_state_from_mapping(load_checkpoint(path))

View File

@ -1,4 +1,4 @@
"""Command line interface for the PAM deploy agent."""
"""PAM 部署 Agent 的命令行入口。"""
from __future__ import annotations
@ -14,17 +14,20 @@ from .params_loader import load_params_file
def add_llm_args(parser: argparse.ArgumentParser) -> None:
"""为子命令追加真实 LLM 配置参数。"""
parser.add_argument("--llm-base-url")
parser.add_argument("--llm-api-key")
parser.add_argument("--llm-model")
def require_confirm(args: argparse.Namespace) -> None:
"""真实执行前强制要求命令行显式传入 --confirm。"""
if not getattr(args, "confirm", False):
raise SystemExit("Refusing to execute actions without --confirm.")
def print_pause_payload(agent: PamDeployAgent, state) -> None:
"""输出 checkpoint 和待确认信息,便于用户续跑或确认。"""
if state.pending_confirmation:
print(json.dumps({"confirmation": agent.build_confirmation_request(state)}, ensure_ascii=False, indent=2))
if state.checkpoint_path:
@ -32,6 +35,7 @@ def print_pause_payload(agent: PamDeployAgent, state) -> None:
def main() -> None:
"""解析 CLI 参数并分发到对应命令。"""
parser = argparse.ArgumentParser(prog="pam-deploy-agent")
sub = parser.add_subparsers(dest="command", required=True)

View File

@ -1,4 +1,4 @@
"""Write script config files for PAM HOME action calls."""
"""为 PAM_HOME 脚本 action 写入 config.txt 风格配置文件。"""
from __future__ import annotations
@ -21,9 +21,9 @@ CONFIG_KEYS = (
def write_config(params: dict[str, Any], path: str | Path) -> Path:
"""按脚本约定的字段顺序生成配置文件,并返回最终路径。"""
config_path = Path(path)
config_path.parent.mkdir(parents=True, exist_ok=True)
lines = [f"{key}={params.get(key, '')}" for key in CONFIG_KEYS]
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return config_path

View File

@ -1,5 +1,6 @@
"""Constants for PAM deploy action routing."""
"""PAM 部署流程中的 action、参数和敏感字段常量。"""
# PAM_HOME 侧只能通过脚本执行的 action。
HOME_ACTIONS = (
"get-token",
"create-version",
@ -8,6 +9,7 @@ HOME_ACTIONS = (
"get-node-url",
)
# PAM_NODE 侧可通过 MCP 或脚本执行的 action。
NODE_ACTIONS = (
"get-online-ips",
"create-download-task",
@ -21,6 +23,7 @@ NODE_ACTIONS = (
"rollback-ip",
)
# 全局阶段按顺序执行,完成后才能进入逐 IP 阶段。
GLOBAL_ACTION_SEQUENCE = (
"get-token",
"create-version",
@ -32,6 +35,7 @@ GLOBAL_ACTION_SEQUENCE = (
"poll-download-progress",
)
# 单个工作站 IP 的部署阶段顺序。
IP_ACTION_SEQUENCE = (
"upgrade-ip",
"poll-upgrade-progress",
@ -40,8 +44,10 @@ IP_ACTION_SEQUENCE = (
"download-log",
)
# Agent 允许规划和执行的完整 action 集合。
ALLOWED_ACTIONS = HOME_ACTIONS + NODE_ACTIONS
# 创建运行状态前必须具备的部署参数。
REQUIRED_PARAMS = (
"HOME_BASE_URL",
"CLIENT_ID",
@ -53,12 +59,14 @@ REQUIRED_PARAMS = (
"ZIP_FILE_PATH",
)
# 用户未显式提供时使用的默认参数。
DEFAULT_PARAMS = {
"ACTION_TYPE": "FULL",
"TIMEOUT": 120,
"LOG_NAME": "app.log",
}
# 日志、报告和 LLM 输入中需要脱敏的字段。
SENSITIVE_KEYS = {
"CLIENT_SECRET",
"TOKEN",
@ -66,4 +74,3 @@ SENSITIVE_KEYS = {
"access_token",
"ACCESS_TOKEN",
}

View File

@ -1,4 +1,4 @@
"""Fake action runner for graph and agent tests."""
"""供本地测试和预演使用的 fake action runner。"""
from __future__ import annotations
@ -8,11 +8,15 @@ from .models import ActionResult
class FakeActionRunner:
"""返回确定性 action 结果,避免测试触碰真实 PAM 环境。"""
def __init__(self, fixtures: dict[str, dict[str, Any]] | None = None) -> None:
"""保存可覆盖默认行为的测试 fixture并记录调用历史。"""
self.fixtures = fixtures or {}
self.calls: list[tuple[str, dict[str, Any]]] = []
def run(self, action: str, *, params: dict[str, Any], **kwargs: Any) -> ActionResult:
"""执行 fake action优先使用 fixture否则使用内置默认结果。"""
self.calls.append((action, kwargs))
values = self._fixture_for(action, kwargs)
if not values:
@ -26,10 +30,11 @@ class FakeActionRunner:
values=values,
exit_code=0 if ok else 1,
raw_output=str(values),
error_summary="" if ok else str(values.get("MESSAGE", "Fake action failed")),
error_summary="" if ok else str(values.get("MESSAGE", "fake action 执行失败")),
)
def _default_values(self, action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
"""为常见部署 action 构造稳定的默认返回值。"""
if action == "get-token":
return {"ACTION": action, "TOKEN": "***"}
if action == "upload-package":
@ -57,6 +62,7 @@ class FakeActionRunner:
return {"ACTION": action, "RESULT": "OK"}
def _fixture_for(self, action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
"""按 action 或 action:ip 查找测试 fixture。"""
ip = kwargs.get("ip")
ip_key = f"{action}:{ip}" if ip else ""
if ip_key and ip_key in self.fixtures:

View File

@ -1,4 +1,4 @@
"""LangGraph integration for the PAM deploy Agent."""
"""PAM 部署 Agent 的 LangGraph 集成入口。"""
from __future__ import annotations
@ -10,17 +10,18 @@ GraphFlow = Literal["global", "deploy"]
def build_langgraph(agent: PamDeployAgent | None = None, flow: GraphFlow = "deploy"):
"""把现有 Agent 节点组装成 LangGraph StateGraph。"""
try:
from langgraph.graph import END, START, StateGraph
except ImportError as exc: # pragma: no cover - depends on optional package
except ImportError as exc: # pragma: no cover - 依赖可选安装状态
raise RuntimeError(
"langgraph is not installed. Install project dependencies with "
"`pip install -e .`."
"未安装 langgraph。请先执行 `pip install -e .` 安装项目依赖。"
) from exc
runtime = agent or PamDeployAgent()
def create_state_node(state: dict[str, Any]) -> dict[str, Any]:
"""根据输入参数创建 AgentState。"""
agent_state = runtime.create_state(
params=state["params"],
execution_strategy=state.get("execution_strategy", "hybrid_node_mcp"),
@ -33,14 +34,17 @@ def build_langgraph(agent: PamDeployAgent | None = None, flow: GraphFlow = "depl
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]:
"""运行逐 IP 部署阶段。"""
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)
@ -61,6 +65,7 @@ def build_langgraph(agent: PamDeployAgent | None = None, flow: GraphFlow = "depl
def build_graph_or_none(agent: PamDeployAgent | None = None, flow: GraphFlow = "deploy"):
"""在未安装 LangGraph 时返回 None便于调用方降级。"""
try:
return build_langgraph(agent=agent, flow=flow)
except RuntimeError:

View File

@ -1,4 +1,4 @@
"""Interactive CLI session for the PAM deploy agent."""
"""PAM 部署 Agent 的常驻式交互 CLI 会话。"""
from __future__ import annotations
@ -32,6 +32,8 @@ COMMAND_HELP = """可用命令:
class InteractiveCliSession:
"""维护一次交互式 CLI 会话的参数、状态和命令处理逻辑。"""
def __init__(
self,
*,
@ -43,6 +45,7 @@ class InteractiveCliSession:
input_func: InputFunc = input,
output_func: OutputFunc = print,
) -> None:
"""初始化会话上下文和输入输出函数。"""
self.agent = agent
self.params = dict(params)
self.strategy = strategy
@ -54,7 +57,8 @@ class InteractiveCliSession:
self.last_analysis: dict[str, Any] | None = None
def run(self) -> None:
self.output("PAM Deploy Agent interactive session")
"""启动 REPL 循环,直到用户 exit 或输入流结束。"""
self.output("PAM 部署 Agent 交互式会话")
self.output("输入 help 查看命令,输入 exit 退出。")
self._load_existing_checkpoint_if_any()
while True:
@ -67,6 +71,7 @@ class InteractiveCliSession:
return
def handle_line(self, line: str) -> bool:
"""处理用户输入的一行命令;返回 False 表示退出会话。"""
text = line.strip()
if not text:
return True
@ -112,6 +117,7 @@ class InteractiveCliSession:
return True
def _analyze(self, text: str) -> None:
"""分析自然语言需求,并更新会话中的参数、策略和目标 IP。"""
if not text:
self.output("请输入要分析的自然语言需求例如analyze 请用 MCP 预演部署 HET。")
return
@ -141,6 +147,7 @@ class InteractiveCliSession:
self.output(_format_redacted_params(safe_payload["params"]["extracted_params"]))
def _set_param(self, assignment: str) -> None:
"""处理 `set KEY=VALUE` 命令,更新当前会话参数。"""
if "=" not in assignment:
self.output("格式set KEY=VALUE")
return
@ -153,6 +160,7 @@ class InteractiveCliSession:
self.output(f"已设置 {key}")
def _run_deploy(self) -> None:
"""在用户确认后创建状态并执行完整部署流程。"""
if self.state and self.state.pending_confirmation:
self._print_confirmation()
return
@ -170,6 +178,7 @@ class InteractiveCliSession:
self._execute_current_state()
def _resume(self) -> None:
"""从内存状态或 checkpoint 文件继续执行部署流程。"""
if self.state is None:
checkpoint = Path(self.checkpoint_path)
if not checkpoint.exists():
@ -180,6 +189,7 @@ class InteractiveCliSession:
self._execute_current_state()
def _execute_current_state(self) -> None:
"""执行当前 state并输出报告、确认提示和 checkpoint 路径。"""
if self.state is None:
self.output("当前没有运行状态。")
return
@ -190,6 +200,7 @@ class InteractiveCliSession:
self.output(f"checkpoint: {self.state.checkpoint_path or self.checkpoint_path}")
def _status(self) -> None:
"""输出当前运行状态;没有 state 时输出 checkpoint 路径。"""
if self.state is None:
self.output("当前还没有运行状态。")
self.output(f"checkpoint: {self.checkpoint_path}")
@ -199,6 +210,7 @@ class InteractiveCliSession:
self._print_confirmation()
def _confirm(self, *, approved: bool, note: str = "") -> None:
"""处理 approve/reject 命令。"""
if self.state is None:
checkpoint = Path(self.checkpoint_path)
if checkpoint.exists():
@ -217,6 +229,7 @@ class InteractiveCliSession:
self._print_confirmation()
def _print_confirmation(self) -> None:
"""输出当前待人工确认事项。"""
if self.state is None:
return
request = self.agent.build_confirmation_request(self.state)
@ -233,6 +246,7 @@ class InteractiveCliSession:
self.output("输入 approve 执行回滚,或 reject [原因] 拒绝回滚。")
def _ask_yes_no(self, prompt: str) -> bool:
"""读取一次 yes/no 确认,只有 yes/y 视为确认。"""
try:
answer = self.input(prompt).strip().lower()
except EOFError:
@ -240,6 +254,7 @@ class InteractiveCliSession:
return answer in ("yes", "y")
def _load_existing_checkpoint_if_any(self) -> None:
"""会话启动时自动加载已存在的 checkpoint。"""
checkpoint = Path(self.checkpoint_path)
if not checkpoint.exists():
return
@ -260,6 +275,7 @@ def run_interactive_chat(
input_func: InputFunc = input,
output_func: OutputFunc = print,
) -> InteractiveCliSession:
"""创建并运行交互式 CLI 会话,返回会话对象便于测试。"""
session = InteractiveCliSession(
agent=agent,
params=params,
@ -274,16 +290,19 @@ def run_interactive_chat(
def _default_checkpoint_path() -> str:
"""生成默认 chat checkpoint 路径。"""
return str(Path("runtime") / "checkpoints" / f"chat_{time.strftime('%Y%m%d_%H%M%S')}.json")
def _choose_strategy(preference: str, default: ExecutionStrategy) -> ExecutionStrategy:
"""根据 LLM 偏好更新执行策略,非法值保留默认策略。"""
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]}")

View File

@ -1,4 +1,4 @@
"""LLM integration surfaces for PAM deploy Agent."""
"""PAM 部署 Agent 的 LLM 集成导出入口。"""
from .base import LlmClient
from .factory import build_llm_client

View File

@ -1,4 +1,4 @@
"""Shared LLM client protocol."""
"""LLM client 需要实现的共享协议。"""
from __future__ import annotations
@ -8,10 +8,14 @@ from pam_deploy_graph.models import ExecutionStrategy, LlmDeployPlan, LlmIntentR
class LlmClient(Protocol):
"""Agent 使用的最小 LLM 能力接口。"""
def understand_request(self, text: str) -> LlmIntentResult:
"""识别用户自然语言请求的意图。"""
...
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
"""从自然语言中抽取部署参数。"""
...
def generate_plan(
@ -21,4 +25,5 @@ class LlmClient(Protocol):
intent: str,
strategy: ExecutionStrategy,
) -> LlmDeployPlan:
"""根据参数和意图生成部署计划。"""
...

View File

@ -1,4 +1,4 @@
"""LLM client factory for CLI and embedding code."""
"""供 CLI 和外部嵌入使用的 LLM client 工厂。"""
from __future__ import annotations
@ -15,6 +15,7 @@ def build_llm_client(
api_key: str | None = None,
model: str | None = None,
) -> LlmClient:
"""根据显式参数或环境变量构造 LLM client。"""
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", "")
@ -30,7 +31,7 @@ def build_llm_client(
if not actual_model:
missing.append("model")
if missing:
raise ValueError(f"Incomplete LLM config: missing {', '.join(missing)}")
raise ValueError(f"LLM 配置不完整,缺少: {', '.join(missing)}")
return OpenAICompatibleLlmClient(
base_url=actual_base_url,

View File

@ -1,8 +1,7 @@
"""OpenAI-compatible HTTP LLM client.
"""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.
client 面向暴露 `/chat/completions` 的模型服务并使用 OpenAI 风格的
请求/响应结构实现只依赖 Python 标准库便于控制运行时依赖体积
"""
from __future__ import annotations
@ -28,6 +27,8 @@ JsonTransport = Callable[[str, dict[str, str], dict[str, Any], float], dict[str,
class OpenAICompatibleLlmClient:
"""通过 OpenAI-compatible HTTP 接口获取结构化 LLM 输出。"""
def __init__(
self,
*,
@ -38,12 +39,13 @@ class OpenAICompatibleLlmClient:
temperature: float = 0,
transport: JsonTransport | None = None,
) -> None:
"""保存连接参数、模型参数和可替换的 HTTP transport。"""
if not base_url:
raise ValueError("LLM base_url is required")
raise ValueError("必须配置 LLM base_url")
if not api_key:
raise ValueError("LLM api_key is required")
raise ValueError("必须配置 LLM api_key")
if not model:
raise ValueError("LLM model is required")
raise ValueError("必须配置 LLM model")
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
@ -52,6 +54,7 @@ class OpenAICompatibleLlmClient:
self.transport = transport or _default_transport
def understand_request(self, text: str) -> LlmIntentResult:
"""调用 LLM 识别用户意图。"""
payload = self._complete_json(INTENT_PROMPT, {"user_text": text})
return LlmIntentResult(
intent=_string(payload, "intent", "deploy"), # type: ignore[arg-type]
@ -64,6 +67,7 @@ class OpenAICompatibleLlmClient:
)
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
"""调用 LLM 抽取参数,并避免把敏感值发送进 prompt。"""
original_base = dict(base_params or {})
safe_base = _redact_sensitive(original_base)
payload = self._complete_json(
@ -102,6 +106,7 @@ class OpenAICompatibleLlmClient:
intent: str,
strategy: ExecutionStrategy,
) -> LlmDeployPlan:
"""调用 LLM 生成部署计划。"""
payload = self._complete_json(
PLAN_PROMPT,
{
@ -115,7 +120,7 @@ class OpenAICompatibleLlmClient:
)
planned_actions = _string_list(payload.get("planned_actions")) or list(GLOBAL_ACTION_SEQUENCE)
return LlmDeployPlan(
summary=_string(payload, "summary", "PAM deployment plan"),
summary=_string(payload, "summary", "PAM 部署计划"),
risk_notes=_string_list(payload.get("risk_notes")),
planned_actions=planned_actions,
requires_confirmation=bool(payload.get("requires_confirmation", True)),
@ -123,6 +128,7 @@ class OpenAICompatibleLlmClient:
)
def _complete_json(self, instruction: str, input_payload: dict[str, Any]) -> dict[str, Any]:
"""发送 chat/completions 请求,并解析 JSON 对象响应。"""
request_payload = {
"model": self.model,
"temperature": self.temperature,
@ -149,7 +155,7 @@ class OpenAICompatibleLlmClient:
content = _message_content(response)
parsed = _loads_json_object(content)
if not isinstance(parsed, dict):
raise ValueError("LLM response must be a JSON object")
raise ValueError("LLM 响应必须是 JSON object")
return parsed
@ -159,6 +165,7 @@ def _default_transport(
payload: dict[str, Any],
timeout_sec: float,
) -> dict[str, Any]:
"""使用标准库 urllib 发送 JSON POST 请求。"""
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
@ -169,11 +176,12 @@ def _default_transport(
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")
raise ValueError("LLM HTTP 响应必须是 JSON object")
return decoded
def _chat_completions_url(base_url: str) -> str:
"""把 base_url 规范化为 chat/completions endpoint。"""
clean = base_url.rstrip("/")
if clean.endswith("/chat/completions"):
return clean
@ -181,10 +189,11 @@ def _chat_completions_url(base_url: str) -> str:
def _message_content(response: dict[str, Any]) -> Any:
"""从 OpenAI-compatible 响应中提取 message.content。"""
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
raise ValueError("LLM 响应缺少 choices[0].message.content") from exc
if isinstance(content, list):
parts: list[str] = []
for item in content:
@ -197,14 +206,16 @@ def _message_content(response: dict[str, Any]) -> Any:
def _loads_json_object(content: Any) -> Any:
"""把 message.content 解析为 JSON 对象。"""
if isinstance(content, dict):
return content
if not isinstance(content, str):
raise ValueError("LLM message content must be JSON text")
raise ValueError("LLM message content 必须是 JSON 文本")
return json.loads(content)
def _redact_sensitive(value: Any) -> Any:
"""递归脱敏 prompt 输入中的敏感字段。"""
if isinstance(value, dict):
redacted: dict[str, Any] = {}
for key, item in value.items():
@ -219,11 +230,13 @@ def _redact_sensitive(value: Any) -> Any:
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):
@ -231,10 +244,12 @@ def _float(payload: dict[str, Any], key: str, default: float) -> float:
def _dict(value: Any) -> dict[str, Any]:
"""确保返回 dict非法值降级为空 dict。"""
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, ""):

View File

@ -1,4 +1,4 @@
"""Prompts for structured PAM deployment planning."""
"""用于 PAM 部署结构化理解和规划的 LLM 提示词。"""
SYSTEM_PROMPT = """你是 PAM 智能部署 Agent 的结构化理解与规划组件。

View File

@ -1,8 +1,7 @@
"""Deterministic fallback for LLM structured outputs.
"""LLM 结构化输出的确定性规则 fallback。
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.
该类不是对真实模型的替代只用于本地开发和测试时提供稳定输出
真实 LLM client 需要实现相同方法
"""
from __future__ import annotations
@ -45,7 +44,10 @@ KEY_ALIASES = {
class RuleBasedLlmClient:
"""基于规则的轻量 LLM client fallback。"""
def understand_request(self, text: str) -> LlmIntentResult:
"""用关键词规则识别用户意图和执行策略偏好。"""
lowered = text.lower()
reasons: list[str] = []
intent = "deploy"
@ -87,6 +89,7 @@ class RuleBasedLlmClient:
)
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
"""从 key=value、中文短语和 IP 地址中抽取参数。"""
params = dict(base_params or {})
params.update(self._extract_key_values(text))
params.update(self._extract_chinese_patterns(text))
@ -112,6 +115,7 @@ class RuleBasedLlmClient:
intent: str,
strategy: ExecutionStrategy,
) -> LlmDeployPlan:
"""生成确定性的部署计划和风险提示。"""
if strategy == "hybrid_node_mcp":
strategy_text = "PAM_HOME 使用脚本 actionPAM_NODE 使用 MCP"
elif strategy == "script_only":
@ -142,6 +146,7 @@ class RuleBasedLlmClient:
)
def _extract_key_values(self, text: str) -> dict[str, str]:
"""抽取 KEY=VALUE 形式的参数。"""
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()
@ -151,6 +156,7 @@ class RuleBasedLlmClient:
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_.-]+)",
@ -164,4 +170,3 @@ class RuleBasedLlmClient:
if match:
params[key] = match.group(1)
return params

View File

@ -1,4 +1,4 @@
"""Validation and guardrails for LLM structured outputs."""
"""LLM 结构化输出的校验和安全护栏。"""
from __future__ import annotations
@ -10,18 +10,20 @@ FORBIDDEN_TEXT = ("bash ", "powershell ", "deploy.sh", "deploy.ps1", "CLIENT_SEC
def validate_intent_result(result: LlmIntentResult) -> None:
"""校验意图识别结果是否合法。"""
if result.intent not in VALID_INTENTS:
raise ValueError(f"Invalid intent: {result.intent}")
raise ValueError(f"非法意图: {result.intent}")
if not 0 <= result.confidence <= 1:
raise ValueError("Intent confidence must be between 0 and 1")
raise ValueError("意图置信度必须在 0 到 1 之间")
def validate_deploy_plan(plan: LlmDeployPlan) -> None:
"""校验部署计划中的 action 和文本安全性。"""
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)}")
raise ValueError(f"计划包含非法 action: {', '.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)}")
raise ValueError(f"计划包含禁止出现的可执行文本: {', '.join(forbidden)}")

View File

@ -1,8 +1,7 @@
"""MCP client adapters.
"""MCP client 适配器。
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.
Agent 只依赖同步的 `call_tool(name, arguments)` 接口本模块把普通
callable SDK session 适配成这个接口避免业务代码绑定具体 MCP SDK
"""
from __future__ import annotations
@ -16,16 +15,17 @@ from typing import Any
@dataclass(frozen=True)
class McpClientConfig:
"""Configuration needed after a real MCP session has been created."""
"""真实 MCP session 建立后需要传给 runner 的配置。"""
server_name: str = "pam-node"
tool_names: dict[str, str] = field(default_factory=dict)
@classmethod
def from_mapping(cls, payload: dict[str, Any]) -> "McpClientConfig":
"""从 JSON 字典构造 MCP client 配置。"""
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")
raise ValueError("MCP tool_names 必须是 JSON 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()},
@ -33,43 +33,49 @@ class McpClientConfig:
def load_mcp_client_config(path: str | Path) -> McpClientConfig:
"""读取 MCP client JSON 配置文件。"""
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")
raise ValueError("MCP client 配置必须是 JSON object")
return McpClientConfig.from_mapping(payload)
class FunctionMcpToolClient:
"""Wrap a plain Python callable as an MCP tool client."""
"""把普通 Python callable 包装为 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`.
"""适配暴露 `call_tool` 的 MCP SDK session。
The adapter accepts common result shapes:
适配器接受常见返回形态
- raw dict/list/string
- object with `structuredContent`
- object with `content`, where text content may contain JSON
- 原始 dict/list/string
- 带有 `structuredContent` 的对象
- 带有 `content` 的对象其中 text 内容可能是 JSON
"""
def __init__(self, session: Any) -> None:
"""校验并保存 MCP SDK session。"""
if not hasattr(session, "call_tool"):
raise TypeError("MCP session must expose call_tool")
raise TypeError("MCP session 必须暴露 call_tool 方法")
self.session = session
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""调用 SDK session并把 SDK 返回值归一化。"""
result = self.session.call_tool(tool_name, arguments)
return normalize_mcp_sdk_result(result)
def normalize_mcp_sdk_result(result: Any) -> Any:
"""把常见 MCP SDK 返回结构归一化成 dict/list/string。"""
if hasattr(result, "structuredContent"):
structured = getattr(result, "structuredContent")
if structured is not None:

View File

@ -1,4 +1,4 @@
"""Runner wrapper for PAM_NODE MCP tools."""
"""PAM_NODE MCP 工具的 action runner 封装。"""
from __future__ import annotations
@ -9,7 +9,10 @@ from .output_parser import parse_mcp_result
class McpToolClient(Protocol):
"""MCP 工具客户端需要实现的最小同步接口。"""
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""调用指定 MCP tool并返回工具原始输出。"""
...
@ -28,11 +31,14 @@ DEFAULT_NODE_MCP_TOOLS = {
class McpActionRunner:
"""把 Agent action 转换为 MCP tool 调用。"""
def __init__(
self,
client: McpToolClient | None = None,
tool_names: dict[str, str] | None = None,
) -> None:
"""保存 MCP client 和 action 到 tool name 的映射。"""
self.client = client
self.tool_names = tool_names or DEFAULT_NODE_MCP_TOOLS.copy()
@ -46,11 +52,12 @@ class McpActionRunner:
stop_first: bool = False,
**_: Any,
) -> ActionResult:
"""执行一个 PAM_NODE action并归一化为 ActionResult。"""
if self.client is None:
raise RuntimeError("MCP client is not configured")
raise RuntimeError("尚未配置 MCP client")
tool_name = self.tool_names.get(action)
if not tool_name:
raise ValueError(f"No MCP tool mapped for action: {action}")
raise ValueError(f"action 未映射 MCP tool: {action}")
arguments = self._build_arguments(
action,
params=params,
@ -60,7 +67,7 @@ class McpActionRunner:
)
try:
payload = self.client.call_tool(tool_name, arguments)
except Exception as exc: # pragma: no cover - defensive wrapper
except Exception as exc: # pragma: no cover - 防御性异常包装
return parse_mcp_result(action, {}, ok=False, tool_name=tool_name, error=str(exc))
return parse_mcp_result(action, payload, ok=True, tool_name=tool_name)
@ -73,6 +80,7 @@ class McpActionRunner:
hash_code: str | None,
stop_first: bool,
) -> dict[str, Any]:
"""把 Agent 参数转换为 MCP tool 所需的入参。"""
arguments = {
"homeBaseUrl": params.get("HOME_BASE_URL"),
"airportCode": params.get("AIRPORT_CODE"),
@ -90,4 +98,3 @@ class McpActionRunner:
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,4 +1,4 @@
"""Shared dataclasses for the PAM deploy agent."""
"""PAM 部署 Agent 共享数据模型。"""
from __future__ import annotations
@ -14,6 +14,8 @@ StrategyPreference = Literal["hybrid_node_mcp", "script_only", "fake", "未指
@dataclass(slots=True)
class ActionResult:
"""单个 action 的统一执行结果。"""
action: str
backend: BackendName
ok: bool
@ -28,6 +30,8 @@ class ActionResult:
@dataclass(slots=True)
class SkillPolicy:
"""从 Skill 文档提取出的部署策略约束。"""
name: str
source_path: str
description: str = ""
@ -51,6 +55,8 @@ class SkillPolicy:
@dataclass(slots=True)
class LlmIntentResult:
"""LLM 意图识别结果。"""
intent: IntentName
mode_preference: ModePreference = "未指定"
strategy_preference: StrategyPreference = "未指定"
@ -62,6 +68,8 @@ class LlmIntentResult:
@dataclass(slots=True)
class LlmParamResult:
"""LLM 参数抽取结果。"""
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)
@ -71,6 +79,8 @@ class LlmParamResult:
@dataclass(slots=True)
class LlmDeployPlan:
"""LLM 生成的部署计划。"""
summary: str
risk_notes: list[str] = field(default_factory=list)
planned_actions: list[str] = field(default_factory=list)
@ -80,6 +90,8 @@ class LlmDeployPlan:
@dataclass(slots=True)
class AgentState:
"""一次部署运行的完整状态,可序列化到 checkpoint。"""
run_id: str
params: dict[str, Any]
execution_strategy: ExecutionStrategy

View File

@ -1,4 +1,4 @@
"""Normalize script stdout and MCP tool returns into ActionResult objects."""
"""把脚本 stdout 和 MCP tool 返回值归一化为 ActionResult。"""
from __future__ import annotations
@ -14,6 +14,7 @@ KEY_VALUE_RE = re.compile(r"^(?P<key>[A-Za-z_][A-Za-z0-9_]*)=(?P<value>.*)$")
def redact_text(text: str) -> str:
"""对文本中的敏感字段值进行脱敏。"""
redacted = text
for key in SENSITIVE_KEYS:
redacted = re.sub(
@ -26,6 +27,7 @@ def redact_text(text: str) -> str:
def parse_key_values(text: str) -> dict[str, Any]:
"""解析脚本输出中的 KEY=VALUE 行,重复 IP 会聚合为列表。"""
values: dict[str, Any] = {}
for raw_line in text.splitlines():
line = raw_line.strip()
@ -42,6 +44,7 @@ def parse_key_values(text: str) -> dict[str, Any]:
def normalize_mcp_values(payload: Any) -> dict[str, Any]:
"""把 MCP 返回值归一化为脚本兼容的字段名。"""
if isinstance(payload, str):
try:
payload = json.loads(payload)
@ -75,6 +78,7 @@ def parse_script_result(
backend: BackendName = "script",
tool_name: str = "",
) -> ActionResult:
"""解析脚本执行结果,并识别待人工确认标记。"""
raw_output = redact_text("\n".join(part for part in (stdout, stderr) if part))
values = parse_key_values(stdout)
pending = PENDING_CONFIRMATION_RE.search(stdout) or PENDING_CONFIRMATION_RE.search(stderr)
@ -105,6 +109,7 @@ def parse_mcp_result(
tool_name: str = "",
error: str = "",
) -> ActionResult:
"""解析 MCP tool 返回值,并包装为统一 ActionResult。"""
values = normalize_mcp_values(payload)
raw_output = redact_text(json.dumps(payload, ensure_ascii=False, default=str))
return ActionResult(
@ -117,11 +122,12 @@ def parse_mcp_result(
stdout=raw_output if ok else "",
stderr=redact_text(error),
raw_output=raw_output,
error_summary="" if ok else redact_text(error or "MCP tool failed"),
error_summary="" if ok else redact_text(error or "MCP tool 执行失败"),
)
def _summarize_error(stderr: str, stdout: str, pending: str) -> str:
"""从 stderr/stdout/确认标记中提取简短错误摘要。"""
if pending:
return pending
for text in (stderr, stdout):
@ -129,5 +135,4 @@ def _summarize_error(stderr: str, stdout: str, pending: str) -> str:
stripped = line.strip()
if stripped:
return redact_text(stripped)
return "Action failed"
return "action 执行失败"

View File

@ -1,4 +1,4 @@
"""Load deploy parameters from JSON or config.txt style files."""
"""从 JSON 或 config.txt 风格文件读取部署参数。"""
from __future__ import annotations
@ -8,6 +8,7 @@ from typing import Any
def load_params_file(path: str | Path) -> dict[str, Any]:
"""自动识别 JSON/config.txt 格式,并返回参数字典。"""
config_path = Path(path)
text = config_path.read_text(encoding="utf-8")
stripped = text.lstrip()
@ -24,4 +25,3 @@ def load_params_file(path: str | Path) -> dict[str, Any]:
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values

View File

@ -1,4 +1,4 @@
"""Subprocess runner for deploy.sh and deploy.ps1 action calls."""
"""通过子进程执行 deploy.sh / deploy.ps1 action。"""
from __future__ import annotations
@ -11,7 +11,10 @@ from .output_parser import parse_script_result
class ScriptActionRunner:
"""脚本 action runner负责构造命令、执行脚本并解析结果。"""
def __init__(self, script_base_dir: str | Path = "doc_scripts") -> None:
"""保存脚本所在目录。"""
self.script_base_dir = Path(script_base_dir)
def run(
@ -27,6 +30,7 @@ class ScriptActionRunner:
trace_file_path: str | None = None,
timeout_sec: int | None = None,
) -> ActionResult:
"""执行一个脚本 action并返回统一 ActionResult。"""
command = self.build_command(
action,
script_entry=script_entry,
@ -64,6 +68,7 @@ class ScriptActionRunner:
stop_first: bool = False,
trace_file_path: str | None = None,
) -> list[str]:
"""根据脚本类型构造 action 命令行参数。"""
if script_entry == "deploy.sh":
command = [
"bash",
@ -101,14 +106,14 @@ class ScriptActionRunner:
command.append("-RollbackStopFirst")
return command
raise ValueError(f"Unsupported script entry: {script_entry}")
raise ValueError(f"不支持的脚本入口: {script_entry}")
def select_script_entry(os_name: str | None = None) -> str:
"""根据操作系统选择默认脚本入口。"""
import platform
name = (os_name or platform.system()).lower()
if "windows" in name:
return "deploy.ps1"
return "deploy.sh"

View File

@ -1,4 +1,4 @@
"""Load the PAM deploy Skill document into a compact policy object."""
"""把 PAM 部署 Skill 文档加载为简化策略对象。"""
from __future__ import annotations
@ -15,6 +15,7 @@ from .models import SkillPolicy
def load_skill_policy(path: str | Path) -> SkillPolicy:
"""读取 Skill markdown 头部信息,并填充 action/参数策略。"""
skill_path = Path(path)
text = skill_path.read_text(encoding="utf-8")
name = "pam-auto-deply"
@ -39,4 +40,3 @@ def load_skill_policy(path: str | Path) -> SkillPolicy:
action_sequence=GLOBAL_ACTION_SEQUENCE,
ip_action_sequence=IP_ACTION_SEQUENCE,
)

View File

@ -17,7 +17,7 @@ def test_build_graph_or_none_without_langgraph_is_safe():
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"):
with pytest.raises(RuntimeError, match="未安装 langgraph"):
build_langgraph()

View File

@ -69,7 +69,7 @@ def test_plan_guardrails_reject_executable_text():
try:
validate_deploy_plan(plan)
except ValueError as exc:
assert "forbidden" in str(exc)
assert "禁止" in str(exc)
else:
raise AssertionError("expected guardrail failure")