docs/build: 补齐中文注释、流程图和 Linux 解压即用打包脚本
- 为 pam_deploy_graph 生产代码补充中文模块、类、函数/方法文档字符串 - 将原有英文说明和主要英文异常提示改为中文 - 新增当前整体逻辑结构流程图文档,覆盖模块结构、执行链路、action 路由、人工确认和 checkpoint 续跑 - 新增 Linux 自带运行环境打包脚本,使用 PyInstaller 生成解压即用目录和 tar.gz - 新增 Linux 打包说明,包含构建命令、运行方式、依赖说明和包大小评估 - 同步 README,补充流程图、打包方式、产物路径和大小预估 - 更新相关测试断言以匹配中文错误提示
This commit is contained in:
parent
1e74ae3cd6
commit
a11904b7c5
37
README.md
37
README.md
@ -39,6 +39,13 @@ tests/
|
|||||||
test_script_runner.py
|
test_script_runner.py
|
||||||
test_skill_policy.py
|
test_skill_policy.py
|
||||||
test_interactive_cli.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
|
```bash
|
||||||
|
|||||||
137
docs/current_logic_flow.md
Normal file
137
docs/current_logic_flow.md
Normal 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` 调用。
|
||||||
55
packaging/README_linux_package.md
Normal file
55
packaging/README_linux_package.md
Normal 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、第三方依赖和动态库决定。
|
||||||
117
packaging/build_linux_self_contained.sh
Normal file
117
packaging/build_linux_self_contained.sh
Normal 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")"
|
||||||
7
packaging/pyinstaller_entry.py
Normal file
7
packaging/pyinstaller_entry.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""PyInstaller 打包入口。"""
|
||||||
|
|
||||||
|
from pam_deploy_graph.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -1,6 +1,5 @@
|
|||||||
"""PAM deploy agent package."""
|
"""PAM 部署 Agent 包入口。"""
|
||||||
|
|
||||||
from .agent import PamDeployAgent
|
from .agent import PamDeployAgent
|
||||||
|
|
||||||
__all__ = ["PamDeployAgent"]
|
__all__ = ["PamDeployAgent"]
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Action routing for HOME script actions and NODE MCP actions."""
|
"""按照执行策略把 action 路由到脚本、MCP 或 fake runner。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -7,6 +7,7 @@ from .models import AgentState, BackendName, ExecutionStrategy, ActionResult
|
|||||||
|
|
||||||
|
|
||||||
def build_action_backends(strategy: ExecutionStrategy) -> dict[str, BackendName]:
|
def build_action_backends(strategy: ExecutionStrategy) -> dict[str, BackendName]:
|
||||||
|
"""根据执行策略生成每个 action 对应的后端类型。"""
|
||||||
if strategy == "fake":
|
if strategy == "fake":
|
||||||
return {action: "fake" for action in ALLOWED_ACTIONS}
|
return {action: "fake" for action in ALLOWED_ACTIONS}
|
||||||
if strategy == "script_only":
|
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: dict[str, BackendName] = {action: "script" for action in HOME_ACTIONS}
|
||||||
routes.update({action: "mcp" for action in NODE_ACTIONS})
|
routes.update({action: "mcp" for action in NODE_ACTIONS})
|
||||||
return routes
|
return routes
|
||||||
raise ValueError(f"Unknown execution strategy: {strategy}")
|
raise ValueError(f"未知执行策略: {strategy}")
|
||||||
|
|
||||||
|
|
||||||
class ActionRouter:
|
class ActionRouter:
|
||||||
|
"""统一的 action 调度器,屏蔽脚本、MCP 和 fake 后端差异。"""
|
||||||
|
|
||||||
def __init__(self, *, script_runner, mcp_runner=None, fake_runner=None) -> None:
|
def __init__(self, *, script_runner, mcp_runner=None, fake_runner=None) -> None:
|
||||||
|
"""保存各类 runner,运行时按 state 中的路由表选择后端。"""
|
||||||
self.script_runner = script_runner
|
self.script_runner = script_runner
|
||||||
self.mcp_runner = mcp_runner
|
self.mcp_runner = mcp_runner
|
||||||
self.fake_runner = fake_runner
|
self.fake_runner = fake_runner
|
||||||
|
|
||||||
def run_action(self, state: AgentState, action: str, **kwargs) -> ActionResult:
|
def run_action(self, state: AgentState, action: str, **kwargs) -> ActionResult:
|
||||||
|
"""执行一个 action,并返回统一的 ActionResult。"""
|
||||||
backend = state.action_backends.get(action)
|
backend = state.action_backends.get(action)
|
||||||
if not backend:
|
if not backend:
|
||||||
raise ValueError(f"Action is not routed: {action}")
|
raise ValueError(f"action 未配置路由: {action}")
|
||||||
if backend == "script":
|
if backend == "script":
|
||||||
return self.script_runner.run(
|
return self.script_runner.run(
|
||||||
action,
|
action,
|
||||||
@ -39,9 +44,8 @@ class ActionRouter:
|
|||||||
)
|
)
|
||||||
if backend == "mcp":
|
if backend == "mcp":
|
||||||
if self.mcp_runner is None:
|
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)
|
return self.mcp_runner.run(action, params=state.params, **kwargs)
|
||||||
if self.fake_runner is None:
|
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)
|
return self.fake_runner.run(action, params=state.params, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""PAM deploy Agent runtime.
|
"""PAM 部署 Agent 运行时。
|
||||||
|
|
||||||
This is intentionally runnable without langgraph installed. The same nodes can
|
本模块不强依赖 LangGraph,可独立运行;同一组节点也可在
|
||||||
be wired into LangGraph later via pam_deploy_graph.graph.
|
`pam_deploy_graph.graph` 中接入 LangGraph。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -23,6 +23,8 @@ from .skill_policy import load_skill_policy
|
|||||||
|
|
||||||
|
|
||||||
class PamDeployAgent:
|
class PamDeployAgent:
|
||||||
|
"""PAM 部署主 Agent,串联 LLM、action 路由、确认和续跑状态。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@ -32,6 +34,7 @@ class PamDeployAgent:
|
|||||||
fake_runner: FakeActionRunner | None = None,
|
fake_runner: FakeActionRunner | None = None,
|
||||||
llm_client: LlmClient | None = None,
|
llm_client: LlmClient | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""初始化策略、脚本 runner、MCP runner、fake runner 和 LLM client。"""
|
||||||
self.skill_policy = load_skill_policy(skill_path)
|
self.skill_policy = load_skill_policy(skill_path)
|
||||||
self.script_base_dir = Path(script_base_dir)
|
self.script_base_dir = Path(script_base_dir)
|
||||||
self.script_runner = ScriptActionRunner(self.script_base_dir)
|
self.script_runner = ScriptActionRunner(self.script_base_dir)
|
||||||
@ -45,11 +48,13 @@ class PamDeployAgent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def understand_request(self, text: str) -> LlmIntentResult:
|
def understand_request(self, text: str) -> LlmIntentResult:
|
||||||
|
"""调用 LLM 识别用户意图,并执行基础校验。"""
|
||||||
result = self.llm_client.understand_request(text)
|
result = self.llm_client.understand_request(text)
|
||||||
validate_intent_result(result)
|
validate_intent_result(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
|
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
|
||||||
|
"""从自然语言中抽取部署参数和控制参数。"""
|
||||||
return self.llm_client.extract_params(text, base_params)
|
return self.llm_client.extract_params(text, base_params)
|
||||||
|
|
||||||
def generate_plan(
|
def generate_plan(
|
||||||
@ -59,11 +64,13 @@ class PamDeployAgent:
|
|||||||
intent: str,
|
intent: str,
|
||||||
strategy: ExecutionStrategy,
|
strategy: ExecutionStrategy,
|
||||||
) -> LlmDeployPlan:
|
) -> LlmDeployPlan:
|
||||||
|
"""根据参数、意图和执行策略生成部署计划。"""
|
||||||
plan = self.llm_client.generate_plan(params=params, intent=intent, strategy=strategy)
|
plan = self.llm_client.generate_plan(params=params, intent=intent, strategy=strategy)
|
||||||
validate_deploy_plan(plan)
|
validate_deploy_plan(plan)
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
def analyze_request(self, text: str, base_params: dict[str, Any] | None = None) -> dict[str, Any]:
|
def analyze_request(self, text: str, base_params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
"""完成意图识别、参数抽取和计划生成,供 analyze/chat 使用。"""
|
||||||
intent = self.understand_request(text)
|
intent = self.understand_request(text)
|
||||||
params = self.extract_params(text, base_params)
|
params = self.extract_params(text, base_params)
|
||||||
strategy = self._choose_strategy(intent.strategy_preference)
|
strategy = self._choose_strategy(intent.strategy_preference)
|
||||||
@ -79,13 +86,15 @@ class PamDeployAgent:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def normalize_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
def normalize_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""合并默认参数并校验必填参数是否齐全。"""
|
||||||
normalized = {**DEFAULT_PARAMS, **params}
|
normalized = {**DEFAULT_PARAMS, **params}
|
||||||
missing = [key for key in REQUIRED_PARAMS if not normalized.get(key)]
|
missing = [key for key in REQUIRED_PARAMS if not normalized.get(key)]
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(f"Missing required params: {', '.join(missing)}")
|
raise ValueError(f"缺少必填参数: {', '.join(missing)}")
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
def _choose_strategy(self, preference: str) -> ExecutionStrategy:
|
def _choose_strategy(self, preference: str) -> ExecutionStrategy:
|
||||||
|
"""把 LLM 给出的策略偏好转换为内部执行策略。"""
|
||||||
if preference in ("hybrid_node_mcp", "script_only", "fake"):
|
if preference in ("hybrid_node_mcp", "script_only", "fake"):
|
||||||
return preference # type: ignore[return-value]
|
return preference # type: ignore[return-value]
|
||||||
return "hybrid_node_mcp"
|
return "hybrid_node_mcp"
|
||||||
@ -102,6 +111,7 @@ class PamDeployAgent:
|
|||||||
checkpoint_path: str | None = None,
|
checkpoint_path: str | None = None,
|
||||||
target_ips: list[str] | None = None,
|
target_ips: list[str] | None = None,
|
||||||
) -> AgentState:
|
) -> AgentState:
|
||||||
|
"""创建一次运行所需的 AgentState,并写入脚本配置文件。"""
|
||||||
normalized = self.normalize_params(params)
|
normalized = self.normalize_params(params)
|
||||||
actual_run_id = run_id or time.strftime("%Y%m%d_%H%M%S")
|
actual_run_id = run_id or time.strftime("%Y%m%d_%H%M%S")
|
||||||
actual_script_entry = script_entry or select_script_entry()
|
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:
|
def preview(self, params: dict[str, Any], strategy: ExecutionStrategy = "hybrid_node_mcp") -> str:
|
||||||
|
"""渲染部署预演,展示参数和 action 路由。"""
|
||||||
normalized = self.normalize_params(params)
|
normalized = self.normalize_params(params)
|
||||||
routes = build_action_backends(strategy)
|
routes = build_action_backends(strategy)
|
||||||
if strategy == "hybrid_node_mcp":
|
if strategy == "hybrid_node_mcp":
|
||||||
@ -153,6 +164,7 @@ class PamDeployAgent:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def run_global_flow(self, state: AgentState) -> AgentState:
|
def run_global_flow(self, state: AgentState) -> AgentState:
|
||||||
|
"""执行全局部署阶段,并跳过 checkpoint 中已完成的步骤。"""
|
||||||
for action in GLOBAL_ACTION_SEQUENCE:
|
for action in GLOBAL_ACTION_SEQUENCE:
|
||||||
if action in state.completed_global_steps:
|
if action in state.completed_global_steps:
|
||||||
continue
|
continue
|
||||||
@ -171,7 +183,7 @@ class PamDeployAgent:
|
|||||||
if not result.ok:
|
if not result.ok:
|
||||||
state.last_failed_step = action
|
state.last_failed_step = action
|
||||||
self._save_checkpoint(state)
|
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)
|
self._apply_result(state, action, result.values)
|
||||||
state.completed_global_steps.append(action)
|
state.completed_global_steps.append(action)
|
||||||
state.last_success_step = action
|
state.last_success_step = action
|
||||||
@ -179,6 +191,7 @@ class PamDeployAgent:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def run_deploy_flow(self, state: AgentState) -> AgentState:
|
def run_deploy_flow(self, state: AgentState) -> AgentState:
|
||||||
|
"""执行完整部署流程:全局阶段后进入逐 IP 阶段。"""
|
||||||
if state.pending_confirmation:
|
if state.pending_confirmation:
|
||||||
self._save_checkpoint(state)
|
self._save_checkpoint(state)
|
||||||
return state
|
return state
|
||||||
@ -187,6 +200,7 @@ class PamDeployAgent:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def run_ip_flow(self, state: AgentState) -> AgentState:
|
def run_ip_flow(self, state: AgentState) -> AgentState:
|
||||||
|
"""执行逐 IP 部署流程,失败时停在人工确认点。"""
|
||||||
if state.pending_confirmation:
|
if state.pending_confirmation:
|
||||||
self._save_checkpoint(state)
|
self._save_checkpoint(state)
|
||||||
return state
|
return state
|
||||||
@ -248,6 +262,7 @@ class PamDeployAgent:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def build_confirmation_request(self, state: AgentState) -> dict[str, Any]:
|
def build_confirmation_request(self, state: AgentState) -> dict[str, Any]:
|
||||||
|
"""把 pending_confirmation 转换为面向用户的确认请求。"""
|
||||||
if not state.pending_confirmation:
|
if not state.pending_confirmation:
|
||||||
return {}
|
return {}
|
||||||
kind, _, value = state.pending_confirmation.partition(":")
|
kind, _, value = state.pending_confirmation.partition(":")
|
||||||
@ -268,11 +283,12 @@ class PamDeployAgent:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def confirm_pending(self, state: AgentState, *, approved: bool, operator_note: str = "") -> AgentState:
|
def confirm_pending(self, state: AgentState, *, approved: bool, operator_note: str = "") -> AgentState:
|
||||||
|
"""处理人工确认结果;当前支持失败 IP 的回滚确认。"""
|
||||||
request = self.build_confirmation_request(state)
|
request = self.build_confirmation_request(state)
|
||||||
if not request:
|
if not request:
|
||||||
raise ValueError("No pending confirmation")
|
raise ValueError("当前没有待确认事项")
|
||||||
if request["type"] != "rollback-ip":
|
if request["type"] != "rollback-ip":
|
||||||
raise ValueError(f"Unsupported confirmation type: {request['type']}")
|
raise ValueError(f"不支持的确认类型: {request['type']}")
|
||||||
|
|
||||||
ip = request["ip"]
|
ip = request["ip"]
|
||||||
ip_state = state.ip_states[ip]
|
ip_state = state.ip_states[ip]
|
||||||
@ -317,6 +333,7 @@ class PamDeployAgent:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def _apply_result(self, state: AgentState, action: str, values: dict[str, Any]) -> None:
|
def _apply_result(self, state: AgentState, action: str, values: dict[str, Any]) -> None:
|
||||||
|
"""把全局 action 返回值写回 AgentState。"""
|
||||||
if "HASH_CODE" in values:
|
if "HASH_CODE" in values:
|
||||||
state.hash_code = str(values["HASH_CODE"])
|
state.hash_code = str(values["HASH_CODE"])
|
||||||
if "NODE_URL" in values:
|
if "NODE_URL" in values:
|
||||||
@ -329,6 +346,7 @@ class PamDeployAgent:
|
|||||||
state.target_ips = state.target_ips or state.online_ips.copy()
|
state.target_ips = state.target_ips or state.online_ips.copy()
|
||||||
|
|
||||||
def _resolve_target_ips(self, state: AgentState) -> None:
|
def _resolve_target_ips(self, state: AgentState) -> None:
|
||||||
|
"""根据在线 IP 和用户指定 IP 计算最终目标 IP。"""
|
||||||
if not state.target_ips:
|
if not state.target_ips:
|
||||||
state.target_ips = state.online_ips.copy()
|
state.target_ips = state.online_ips.copy()
|
||||||
return
|
return
|
||||||
@ -347,6 +365,7 @@ class PamDeployAgent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _business_failed(self, action: str, values: dict[str, Any]) -> bool:
|
def _business_failed(self, action: str, values: dict[str, Any]) -> bool:
|
||||||
|
"""识别 exit code 之外的业务失败条件。"""
|
||||||
if action == "verify-ip":
|
if action == "verify-ip":
|
||||||
success = values.get("SUCCESS")
|
success = values.get("SUCCESS")
|
||||||
if success is None:
|
if success is None:
|
||||||
@ -355,10 +374,12 @@ class PamDeployAgent:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _apply_ip_result(self, ip_state: dict[str, Any], action: str, values: dict[str, Any]) -> None:
|
def _apply_ip_result(self, ip_state: dict[str, Any], action: str, values: dict[str, Any]) -> None:
|
||||||
|
"""把逐 IP action 返回值写回单 IP 状态。"""
|
||||||
if action == "download-log":
|
if action == "download-log":
|
||||||
ip_state["log_file"] = str(values.get("LOG_FILE", ""))
|
ip_state["log_file"] = str(values.get("LOG_FILE", ""))
|
||||||
|
|
||||||
def _record_ip_failure(self, state: AgentState, ip: str, action: str, reason: str) -> None:
|
def _record_ip_failure(self, state: AgentState, ip: str, action: str, reason: str) -> None:
|
||||||
|
"""记录单 IP 失败,并设置待回滚确认状态。"""
|
||||||
ip_state = state.ip_states[ip]
|
ip_state = state.ip_states[ip]
|
||||||
stop_first = action in ("start-ip", "verify-ip")
|
stop_first = action in ("start-ip", "verify-ip")
|
||||||
ip_state.update(
|
ip_state.update(
|
||||||
@ -373,15 +394,16 @@ class PamDeployAgent:
|
|||||||
state.last_failed_step = action
|
state.last_failed_step = action
|
||||||
state.events.append(
|
state.events.append(
|
||||||
{
|
{
|
||||||
"type": "CONFIRMATION_REQUIRED",
|
"type": "CONFIRMATION_REQUIRED",
|
||||||
"stage": "rollback-ip",
|
"stage": "rollback-ip",
|
||||||
"ip": ip,
|
"ip": ip,
|
||||||
"stop_first": stop_first,
|
"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:
|
def _download_log_best_effort(self, state: AgentState, ip: str) -> None:
|
||||||
|
"""失败后尽力下载日志,日志失败不覆盖原失败原因。"""
|
||||||
result = self.router.run_action(state, "download-log", ip=ip)
|
result = self.router.run_action(state, "download-log", ip=ip)
|
||||||
ip_state = state.ip_states[ip]
|
ip_state = state.ip_states[ip]
|
||||||
if result.ok:
|
if result.ok:
|
||||||
@ -392,7 +414,7 @@ class PamDeployAgent:
|
|||||||
"stage": "download-log",
|
"stage": "download-log",
|
||||||
"backend": result.backend,
|
"backend": result.backend,
|
||||||
"ip": ip,
|
"ip": ip,
|
||||||
"message": "best effort log downloaded",
|
"message": "已尽力下载日志",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -402,15 +424,17 @@ class PamDeployAgent:
|
|||||||
"stage": "download-log",
|
"stage": "download-log",
|
||||||
"backend": result.backend,
|
"backend": result.backend,
|
||||||
"ip": ip,
|
"ip": ip,
|
||||||
"message": result.error_summary or "best effort log download failed",
|
"message": result.error_summary or "尽力下载日志失败",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _save_checkpoint(self, state: AgentState) -> None:
|
def _save_checkpoint(self, state: AgentState) -> None:
|
||||||
|
"""如果配置了 checkpoint 路径,则保存完整运行状态。"""
|
||||||
if state.checkpoint_path:
|
if state.checkpoint_path:
|
||||||
save_checkpoint(state, state.checkpoint_path, redact=False)
|
save_checkpoint(state, state.checkpoint_path, redact=False)
|
||||||
|
|
||||||
def render_report(self, state: AgentState) -> str:
|
def render_report(self, state: AgentState) -> str:
|
||||||
|
"""渲染当前部署状态报告。"""
|
||||||
success = sum(1 for item in state.ip_states.values() if item.get("status") == "SUCCESS")
|
success = sum(1 for item in state.ip_states.values() if item.get("status") == "SUCCESS")
|
||||||
failed = sum(1 for item in state.ip_states.values() if item.get("status") == "FAILED")
|
failed = sum(1 for item in state.ip_states.values() if item.get("status") == "FAILED")
|
||||||
lines = [
|
lines = [
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Business checkpoint JSON storage."""
|
"""业务 checkpoint 的 JSON 存储与恢复工具。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ from .models import AgentState
|
|||||||
|
|
||||||
|
|
||||||
def redact_mapping(value: Any) -> Any:
|
def redact_mapping(value: Any) -> Any:
|
||||||
|
"""递归脱敏 dict/list 中的敏感字段。"""
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
result = {}
|
result = {}
|
||||||
for key, item in value.items():
|
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:
|
def save_checkpoint(state: Any, path: str | Path, *, redact: bool = True) -> Path:
|
||||||
|
"""保存 checkpoint;真实续跑场景可关闭脱敏以保留必要参数。"""
|
||||||
checkpoint_path = Path(path)
|
checkpoint_path = Path(path)
|
||||||
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
|
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
payload = asdict(state) if is_dataclass(state) else state
|
payload = asdict(state) if is_dataclass(state) else state
|
||||||
@ -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]:
|
def load_checkpoint(path: str | Path) -> dict[str, Any]:
|
||||||
|
"""从 JSON 文件读取原始 checkpoint 字典。"""
|
||||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def agent_state_from_mapping(payload: dict[str, Any]) -> AgentState:
|
def agent_state_from_mapping(payload: dict[str, Any]) -> AgentState:
|
||||||
|
"""把 checkpoint 字典转换回 AgentState,忽略未知字段。"""
|
||||||
allowed_fields = {item.name for item in fields(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}
|
state_payload = {key: value for key, value in payload.items() if key in allowed_fields}
|
||||||
return AgentState(**state_payload)
|
return AgentState(**state_payload)
|
||||||
|
|
||||||
|
|
||||||
def load_agent_state(path: str | Path) -> AgentState:
|
def load_agent_state(path: str | Path) -> AgentState:
|
||||||
|
"""读取 checkpoint 文件并恢复 AgentState。"""
|
||||||
return agent_state_from_mapping(load_checkpoint(path))
|
return agent_state_from_mapping(load_checkpoint(path))
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Command line interface for the PAM deploy agent."""
|
"""PAM 部署 Agent 的命令行入口。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -14,17 +14,20 @@ from .params_loader import load_params_file
|
|||||||
|
|
||||||
|
|
||||||
def add_llm_args(parser: argparse.ArgumentParser) -> None:
|
def add_llm_args(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""为子命令追加真实 LLM 配置参数。"""
|
||||||
parser.add_argument("--llm-base-url")
|
parser.add_argument("--llm-base-url")
|
||||||
parser.add_argument("--llm-api-key")
|
parser.add_argument("--llm-api-key")
|
||||||
parser.add_argument("--llm-model")
|
parser.add_argument("--llm-model")
|
||||||
|
|
||||||
|
|
||||||
def require_confirm(args: argparse.Namespace) -> None:
|
def require_confirm(args: argparse.Namespace) -> None:
|
||||||
|
"""真实执行前强制要求命令行显式传入 --confirm。"""
|
||||||
if not getattr(args, "confirm", False):
|
if not getattr(args, "confirm", False):
|
||||||
raise SystemExit("Refusing to execute actions without --confirm.")
|
raise SystemExit("Refusing to execute actions without --confirm.")
|
||||||
|
|
||||||
|
|
||||||
def print_pause_payload(agent: PamDeployAgent, state) -> None:
|
def print_pause_payload(agent: PamDeployAgent, state) -> None:
|
||||||
|
"""输出 checkpoint 和待确认信息,便于用户续跑或确认。"""
|
||||||
if state.pending_confirmation:
|
if state.pending_confirmation:
|
||||||
print(json.dumps({"confirmation": agent.build_confirmation_request(state)}, ensure_ascii=False, indent=2))
|
print(json.dumps({"confirmation": agent.build_confirmation_request(state)}, ensure_ascii=False, indent=2))
|
||||||
if state.checkpoint_path:
|
if state.checkpoint_path:
|
||||||
@ -32,6 +35,7 @@ def print_pause_payload(agent: PamDeployAgent, state) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""解析 CLI 参数并分发到对应命令。"""
|
||||||
parser = argparse.ArgumentParser(prog="pam-deploy-agent")
|
parser = argparse.ArgumentParser(prog="pam-deploy-agent")
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Write script config files for PAM HOME action calls."""
|
"""为 PAM_HOME 脚本 action 写入 config.txt 风格配置文件。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -21,9 +21,9 @@ CONFIG_KEYS = (
|
|||||||
|
|
||||||
|
|
||||||
def write_config(params: dict[str, Any], path: str | Path) -> Path:
|
def write_config(params: dict[str, Any], path: str | Path) -> Path:
|
||||||
|
"""按脚本约定的字段顺序生成配置文件,并返回最终路径。"""
|
||||||
config_path = Path(path)
|
config_path = Path(path)
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
lines = [f"{key}={params.get(key, '')}" for key in CONFIG_KEYS]
|
lines = [f"{key}={params.get(key, '')}" for key in CONFIG_KEYS]
|
||||||
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""Constants for PAM deploy action routing."""
|
"""PAM 部署流程中的 action、参数和敏感字段常量。"""
|
||||||
|
|
||||||
|
# PAM_HOME 侧只能通过脚本执行的 action。
|
||||||
HOME_ACTIONS = (
|
HOME_ACTIONS = (
|
||||||
"get-token",
|
"get-token",
|
||||||
"create-version",
|
"create-version",
|
||||||
@ -8,6 +9,7 @@ HOME_ACTIONS = (
|
|||||||
"get-node-url",
|
"get-node-url",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# PAM_NODE 侧可通过 MCP 或脚本执行的 action。
|
||||||
NODE_ACTIONS = (
|
NODE_ACTIONS = (
|
||||||
"get-online-ips",
|
"get-online-ips",
|
||||||
"create-download-task",
|
"create-download-task",
|
||||||
@ -21,6 +23,7 @@ NODE_ACTIONS = (
|
|||||||
"rollback-ip",
|
"rollback-ip",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 全局阶段按顺序执行,完成后才能进入逐 IP 阶段。
|
||||||
GLOBAL_ACTION_SEQUENCE = (
|
GLOBAL_ACTION_SEQUENCE = (
|
||||||
"get-token",
|
"get-token",
|
||||||
"create-version",
|
"create-version",
|
||||||
@ -32,6 +35,7 @@ GLOBAL_ACTION_SEQUENCE = (
|
|||||||
"poll-download-progress",
|
"poll-download-progress",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 单个工作站 IP 的部署阶段顺序。
|
||||||
IP_ACTION_SEQUENCE = (
|
IP_ACTION_SEQUENCE = (
|
||||||
"upgrade-ip",
|
"upgrade-ip",
|
||||||
"poll-upgrade-progress",
|
"poll-upgrade-progress",
|
||||||
@ -40,8 +44,10 @@ IP_ACTION_SEQUENCE = (
|
|||||||
"download-log",
|
"download-log",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Agent 允许规划和执行的完整 action 集合。
|
||||||
ALLOWED_ACTIONS = HOME_ACTIONS + NODE_ACTIONS
|
ALLOWED_ACTIONS = HOME_ACTIONS + NODE_ACTIONS
|
||||||
|
|
||||||
|
# 创建运行状态前必须具备的部署参数。
|
||||||
REQUIRED_PARAMS = (
|
REQUIRED_PARAMS = (
|
||||||
"HOME_BASE_URL",
|
"HOME_BASE_URL",
|
||||||
"CLIENT_ID",
|
"CLIENT_ID",
|
||||||
@ -53,12 +59,14 @@ REQUIRED_PARAMS = (
|
|||||||
"ZIP_FILE_PATH",
|
"ZIP_FILE_PATH",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 用户未显式提供时使用的默认参数。
|
||||||
DEFAULT_PARAMS = {
|
DEFAULT_PARAMS = {
|
||||||
"ACTION_TYPE": "FULL",
|
"ACTION_TYPE": "FULL",
|
||||||
"TIMEOUT": 120,
|
"TIMEOUT": 120,
|
||||||
"LOG_NAME": "app.log",
|
"LOG_NAME": "app.log",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 日志、报告和 LLM 输入中需要脱敏的字段。
|
||||||
SENSITIVE_KEYS = {
|
SENSITIVE_KEYS = {
|
||||||
"CLIENT_SECRET",
|
"CLIENT_SECRET",
|
||||||
"TOKEN",
|
"TOKEN",
|
||||||
@ -66,4 +74,3 @@ SENSITIVE_KEYS = {
|
|||||||
"access_token",
|
"access_token",
|
||||||
"ACCESS_TOKEN",
|
"ACCESS_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Fake action runner for graph and agent tests."""
|
"""供本地测试和预演使用的 fake action runner。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -8,11 +8,15 @@ from .models import ActionResult
|
|||||||
|
|
||||||
|
|
||||||
class FakeActionRunner:
|
class FakeActionRunner:
|
||||||
|
"""返回确定性 action 结果,避免测试触碰真实 PAM 环境。"""
|
||||||
|
|
||||||
def __init__(self, fixtures: dict[str, dict[str, Any]] | None = None) -> None:
|
def __init__(self, fixtures: dict[str, dict[str, Any]] | None = None) -> None:
|
||||||
|
"""保存可覆盖默认行为的测试 fixture,并记录调用历史。"""
|
||||||
self.fixtures = fixtures or {}
|
self.fixtures = fixtures or {}
|
||||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
def run(self, action: str, *, params: dict[str, Any], **kwargs: Any) -> ActionResult:
|
def run(self, action: str, *, params: dict[str, Any], **kwargs: Any) -> ActionResult:
|
||||||
|
"""执行 fake action,优先使用 fixture,否则使用内置默认结果。"""
|
||||||
self.calls.append((action, kwargs))
|
self.calls.append((action, kwargs))
|
||||||
values = self._fixture_for(action, kwargs)
|
values = self._fixture_for(action, kwargs)
|
||||||
if not values:
|
if not values:
|
||||||
@ -26,10 +30,11 @@ class FakeActionRunner:
|
|||||||
values=values,
|
values=values,
|
||||||
exit_code=0 if ok else 1,
|
exit_code=0 if ok else 1,
|
||||||
raw_output=str(values),
|
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]:
|
def _default_values(self, action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""为常见部署 action 构造稳定的默认返回值。"""
|
||||||
if action == "get-token":
|
if action == "get-token":
|
||||||
return {"ACTION": action, "TOKEN": "***"}
|
return {"ACTION": action, "TOKEN": "***"}
|
||||||
if action == "upload-package":
|
if action == "upload-package":
|
||||||
@ -57,6 +62,7 @@ class FakeActionRunner:
|
|||||||
return {"ACTION": action, "RESULT": "OK"}
|
return {"ACTION": action, "RESULT": "OK"}
|
||||||
|
|
||||||
def _fixture_for(self, action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
def _fixture_for(self, action: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""按 action 或 action:ip 查找测试 fixture。"""
|
||||||
ip = kwargs.get("ip")
|
ip = kwargs.get("ip")
|
||||||
ip_key = f"{action}:{ip}" if ip else ""
|
ip_key = f"{action}:{ip}" if ip else ""
|
||||||
if ip_key and ip_key in self.fixtures:
|
if ip_key and ip_key in self.fixtures:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""LangGraph integration for the PAM deploy Agent."""
|
"""PAM 部署 Agent 的 LangGraph 集成入口。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -10,17 +10,18 @@ GraphFlow = Literal["global", "deploy"]
|
|||||||
|
|
||||||
|
|
||||||
def build_langgraph(agent: PamDeployAgent | None = None, flow: GraphFlow = "deploy"):
|
def build_langgraph(agent: PamDeployAgent | None = None, flow: GraphFlow = "deploy"):
|
||||||
|
"""把现有 Agent 节点组装成 LangGraph StateGraph。"""
|
||||||
try:
|
try:
|
||||||
from langgraph.graph import END, START, StateGraph
|
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(
|
raise RuntimeError(
|
||||||
"langgraph is not installed. Install project dependencies with "
|
"未安装 langgraph。请先执行 `pip install -e .` 安装项目依赖。"
|
||||||
"`pip install -e .`."
|
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
runtime = agent or PamDeployAgent()
|
runtime = agent or PamDeployAgent()
|
||||||
|
|
||||||
def create_state_node(state: dict[str, Any]) -> dict[str, Any]:
|
def create_state_node(state: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""根据输入参数创建 AgentState。"""
|
||||||
agent_state = runtime.create_state(
|
agent_state = runtime.create_state(
|
||||||
params=state["params"],
|
params=state["params"],
|
||||||
execution_strategy=state.get("execution_strategy", "hybrid_node_mcp"),
|
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}
|
return {"agent_state": agent_state}
|
||||||
|
|
||||||
def run_global_node(state: dict[str, Any]) -> dict[str, Any]:
|
def run_global_node(state: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""运行全局部署阶段。"""
|
||||||
agent_state = runtime.run_global_flow(state["agent_state"])
|
agent_state = runtime.run_global_flow(state["agent_state"])
|
||||||
return {"agent_state": agent_state}
|
return {"agent_state": agent_state}
|
||||||
|
|
||||||
def run_ip_node(state: dict[str, Any]) -> dict[str, Any]:
|
def run_ip_node(state: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""运行逐 IP 部署阶段。"""
|
||||||
agent_state = runtime.run_ip_flow(state["agent_state"])
|
agent_state = runtime.run_ip_flow(state["agent_state"])
|
||||||
return {"agent_state": agent_state}
|
return {"agent_state": agent_state}
|
||||||
|
|
||||||
def report_node(state: dict[str, Any]) -> dict[str, Any]:
|
def report_node(state: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""渲染最终部署报告。"""
|
||||||
return {"report": runtime.render_report(state["agent_state"])}
|
return {"report": runtime.render_report(state["agent_state"])}
|
||||||
|
|
||||||
graph = StateGraph(dict)
|
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"):
|
def build_graph_or_none(agent: PamDeployAgent | None = None, flow: GraphFlow = "deploy"):
|
||||||
|
"""在未安装 LangGraph 时返回 None,便于调用方降级。"""
|
||||||
try:
|
try:
|
||||||
return build_langgraph(agent=agent, flow=flow)
|
return build_langgraph(agent=agent, flow=flow)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Interactive CLI session for the PAM deploy agent."""
|
"""PAM 部署 Agent 的常驻式交互 CLI 会话。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -32,6 +32,8 @@ COMMAND_HELP = """可用命令:
|
|||||||
|
|
||||||
|
|
||||||
class InteractiveCliSession:
|
class InteractiveCliSession:
|
||||||
|
"""维护一次交互式 CLI 会话的参数、状态和命令处理逻辑。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@ -43,6 +45,7 @@ class InteractiveCliSession:
|
|||||||
input_func: InputFunc = input,
|
input_func: InputFunc = input,
|
||||||
output_func: OutputFunc = print,
|
output_func: OutputFunc = print,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""初始化会话上下文和输入输出函数。"""
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
self.params = dict(params)
|
self.params = dict(params)
|
||||||
self.strategy = strategy
|
self.strategy = strategy
|
||||||
@ -54,7 +57,8 @@ class InteractiveCliSession:
|
|||||||
self.last_analysis: dict[str, Any] | None = None
|
self.last_analysis: dict[str, Any] | None = None
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.output("PAM Deploy Agent interactive session")
|
"""启动 REPL 循环,直到用户 exit 或输入流结束。"""
|
||||||
|
self.output("PAM 部署 Agent 交互式会话")
|
||||||
self.output("输入 help 查看命令,输入 exit 退出。")
|
self.output("输入 help 查看命令,输入 exit 退出。")
|
||||||
self._load_existing_checkpoint_if_any()
|
self._load_existing_checkpoint_if_any()
|
||||||
while True:
|
while True:
|
||||||
@ -67,6 +71,7 @@ class InteractiveCliSession:
|
|||||||
return
|
return
|
||||||
|
|
||||||
def handle_line(self, line: str) -> bool:
|
def handle_line(self, line: str) -> bool:
|
||||||
|
"""处理用户输入的一行命令;返回 False 表示退出会话。"""
|
||||||
text = line.strip()
|
text = line.strip()
|
||||||
if not text:
|
if not text:
|
||||||
return True
|
return True
|
||||||
@ -112,6 +117,7 @@ class InteractiveCliSession:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _analyze(self, text: str) -> None:
|
def _analyze(self, text: str) -> None:
|
||||||
|
"""分析自然语言需求,并更新会话中的参数、策略和目标 IP。"""
|
||||||
if not text:
|
if not text:
|
||||||
self.output("请输入要分析的自然语言需求,例如:analyze 请用 MCP 预演部署 HET。")
|
self.output("请输入要分析的自然语言需求,例如:analyze 请用 MCP 预演部署 HET。")
|
||||||
return
|
return
|
||||||
@ -141,6 +147,7 @@ class InteractiveCliSession:
|
|||||||
self.output(_format_redacted_params(safe_payload["params"]["extracted_params"]))
|
self.output(_format_redacted_params(safe_payload["params"]["extracted_params"]))
|
||||||
|
|
||||||
def _set_param(self, assignment: str) -> None:
|
def _set_param(self, assignment: str) -> None:
|
||||||
|
"""处理 `set KEY=VALUE` 命令,更新当前会话参数。"""
|
||||||
if "=" not in assignment:
|
if "=" not in assignment:
|
||||||
self.output("格式:set KEY=VALUE")
|
self.output("格式:set KEY=VALUE")
|
||||||
return
|
return
|
||||||
@ -153,6 +160,7 @@ class InteractiveCliSession:
|
|||||||
self.output(f"已设置 {key}")
|
self.output(f"已设置 {key}")
|
||||||
|
|
||||||
def _run_deploy(self) -> None:
|
def _run_deploy(self) -> None:
|
||||||
|
"""在用户确认后创建状态并执行完整部署流程。"""
|
||||||
if self.state and self.state.pending_confirmation:
|
if self.state and self.state.pending_confirmation:
|
||||||
self._print_confirmation()
|
self._print_confirmation()
|
||||||
return
|
return
|
||||||
@ -170,6 +178,7 @@ class InteractiveCliSession:
|
|||||||
self._execute_current_state()
|
self._execute_current_state()
|
||||||
|
|
||||||
def _resume(self) -> None:
|
def _resume(self) -> None:
|
||||||
|
"""从内存状态或 checkpoint 文件继续执行部署流程。"""
|
||||||
if self.state is None:
|
if self.state is None:
|
||||||
checkpoint = Path(self.checkpoint_path)
|
checkpoint = Path(self.checkpoint_path)
|
||||||
if not checkpoint.exists():
|
if not checkpoint.exists():
|
||||||
@ -180,6 +189,7 @@ class InteractiveCliSession:
|
|||||||
self._execute_current_state()
|
self._execute_current_state()
|
||||||
|
|
||||||
def _execute_current_state(self) -> None:
|
def _execute_current_state(self) -> None:
|
||||||
|
"""执行当前 state,并输出报告、确认提示和 checkpoint 路径。"""
|
||||||
if self.state is None:
|
if self.state is None:
|
||||||
self.output("当前没有运行状态。")
|
self.output("当前没有运行状态。")
|
||||||
return
|
return
|
||||||
@ -190,6 +200,7 @@ class InteractiveCliSession:
|
|||||||
self.output(f"checkpoint: {self.state.checkpoint_path or self.checkpoint_path}")
|
self.output(f"checkpoint: {self.state.checkpoint_path or self.checkpoint_path}")
|
||||||
|
|
||||||
def _status(self) -> None:
|
def _status(self) -> None:
|
||||||
|
"""输出当前运行状态;没有 state 时输出 checkpoint 路径。"""
|
||||||
if self.state is None:
|
if self.state is None:
|
||||||
self.output("当前还没有运行状态。")
|
self.output("当前还没有运行状态。")
|
||||||
self.output(f"checkpoint: {self.checkpoint_path}")
|
self.output(f"checkpoint: {self.checkpoint_path}")
|
||||||
@ -199,6 +210,7 @@ class InteractiveCliSession:
|
|||||||
self._print_confirmation()
|
self._print_confirmation()
|
||||||
|
|
||||||
def _confirm(self, *, approved: bool, note: str = "") -> None:
|
def _confirm(self, *, approved: bool, note: str = "") -> None:
|
||||||
|
"""处理 approve/reject 命令。"""
|
||||||
if self.state is None:
|
if self.state is None:
|
||||||
checkpoint = Path(self.checkpoint_path)
|
checkpoint = Path(self.checkpoint_path)
|
||||||
if checkpoint.exists():
|
if checkpoint.exists():
|
||||||
@ -217,6 +229,7 @@ class InteractiveCliSession:
|
|||||||
self._print_confirmation()
|
self._print_confirmation()
|
||||||
|
|
||||||
def _print_confirmation(self) -> None:
|
def _print_confirmation(self) -> None:
|
||||||
|
"""输出当前待人工确认事项。"""
|
||||||
if self.state is None:
|
if self.state is None:
|
||||||
return
|
return
|
||||||
request = self.agent.build_confirmation_request(self.state)
|
request = self.agent.build_confirmation_request(self.state)
|
||||||
@ -233,6 +246,7 @@ class InteractiveCliSession:
|
|||||||
self.output("输入 approve 执行回滚,或 reject [原因] 拒绝回滚。")
|
self.output("输入 approve 执行回滚,或 reject [原因] 拒绝回滚。")
|
||||||
|
|
||||||
def _ask_yes_no(self, prompt: str) -> bool:
|
def _ask_yes_no(self, prompt: str) -> bool:
|
||||||
|
"""读取一次 yes/no 确认,只有 yes/y 视为确认。"""
|
||||||
try:
|
try:
|
||||||
answer = self.input(prompt).strip().lower()
|
answer = self.input(prompt).strip().lower()
|
||||||
except EOFError:
|
except EOFError:
|
||||||
@ -240,6 +254,7 @@ class InteractiveCliSession:
|
|||||||
return answer in ("yes", "y")
|
return answer in ("yes", "y")
|
||||||
|
|
||||||
def _load_existing_checkpoint_if_any(self) -> None:
|
def _load_existing_checkpoint_if_any(self) -> None:
|
||||||
|
"""会话启动时自动加载已存在的 checkpoint。"""
|
||||||
checkpoint = Path(self.checkpoint_path)
|
checkpoint = Path(self.checkpoint_path)
|
||||||
if not checkpoint.exists():
|
if not checkpoint.exists():
|
||||||
return
|
return
|
||||||
@ -260,6 +275,7 @@ def run_interactive_chat(
|
|||||||
input_func: InputFunc = input,
|
input_func: InputFunc = input,
|
||||||
output_func: OutputFunc = print,
|
output_func: OutputFunc = print,
|
||||||
) -> InteractiveCliSession:
|
) -> InteractiveCliSession:
|
||||||
|
"""创建并运行交互式 CLI 会话,返回会话对象便于测试。"""
|
||||||
session = InteractiveCliSession(
|
session = InteractiveCliSession(
|
||||||
agent=agent,
|
agent=agent,
|
||||||
params=params,
|
params=params,
|
||||||
@ -274,16 +290,19 @@ def run_interactive_chat(
|
|||||||
|
|
||||||
|
|
||||||
def _default_checkpoint_path() -> str:
|
def _default_checkpoint_path() -> str:
|
||||||
|
"""生成默认 chat checkpoint 路径。"""
|
||||||
return str(Path("runtime") / "checkpoints" / f"chat_{time.strftime('%Y%m%d_%H%M%S')}.json")
|
return str(Path("runtime") / "checkpoints" / f"chat_{time.strftime('%Y%m%d_%H%M%S')}.json")
|
||||||
|
|
||||||
|
|
||||||
def _choose_strategy(preference: str, default: ExecutionStrategy) -> ExecutionStrategy:
|
def _choose_strategy(preference: str, default: ExecutionStrategy) -> ExecutionStrategy:
|
||||||
|
"""根据 LLM 偏好更新执行策略,非法值保留默认策略。"""
|
||||||
if preference in ("hybrid_node_mcp", "script_only", "fake"):
|
if preference in ("hybrid_node_mcp", "script_only", "fake"):
|
||||||
return preference # type: ignore[return-value]
|
return preference # type: ignore[return-value]
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _format_redacted_params(params: dict[str, Any]) -> str:
|
def _format_redacted_params(params: dict[str, Any]) -> str:
|
||||||
|
"""把脱敏后的参数字典格式化为多行文本。"""
|
||||||
lines = ["当前参数:"]
|
lines = ["当前参数:"]
|
||||||
for key in sorted(params):
|
for key in sorted(params):
|
||||||
lines.append(f"- {key}: {params[key]}")
|
lines.append(f"- {key}: {params[key]}")
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""LLM integration surfaces for PAM deploy Agent."""
|
"""PAM 部署 Agent 的 LLM 集成导出入口。"""
|
||||||
|
|
||||||
from .base import LlmClient
|
from .base import LlmClient
|
||||||
from .factory import build_llm_client
|
from .factory import build_llm_client
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Shared LLM client protocol."""
|
"""LLM client 需要实现的共享协议。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -8,10 +8,14 @@ from pam_deploy_graph.models import ExecutionStrategy, LlmDeployPlan, LlmIntentR
|
|||||||
|
|
||||||
|
|
||||||
class LlmClient(Protocol):
|
class LlmClient(Protocol):
|
||||||
|
"""Agent 使用的最小 LLM 能力接口。"""
|
||||||
|
|
||||||
def understand_request(self, text: str) -> LlmIntentResult:
|
def understand_request(self, text: str) -> LlmIntentResult:
|
||||||
|
"""识别用户自然语言请求的意图。"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
|
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
|
||||||
|
"""从自然语言中抽取部署参数。"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def generate_plan(
|
def generate_plan(
|
||||||
@ -21,4 +25,5 @@ class LlmClient(Protocol):
|
|||||||
intent: str,
|
intent: str,
|
||||||
strategy: ExecutionStrategy,
|
strategy: ExecutionStrategy,
|
||||||
) -> LlmDeployPlan:
|
) -> LlmDeployPlan:
|
||||||
|
"""根据参数和意图生成部署计划。"""
|
||||||
...
|
...
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""LLM client factory for CLI and embedding code."""
|
"""供 CLI 和外部嵌入使用的 LLM client 工厂。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -15,6 +15,7 @@ def build_llm_client(
|
|||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
) -> LlmClient:
|
) -> LlmClient:
|
||||||
|
"""根据显式参数或环境变量构造 LLM client。"""
|
||||||
actual_base_url = base_url or os.getenv("PAM_LLM_BASE_URL", "")
|
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_api_key = api_key or os.getenv("PAM_LLM_API_KEY", "")
|
||||||
actual_model = model or os.getenv("PAM_LLM_MODEL", "")
|
actual_model = model or os.getenv("PAM_LLM_MODEL", "")
|
||||||
@ -30,7 +31,7 @@ def build_llm_client(
|
|||||||
if not actual_model:
|
if not actual_model:
|
||||||
missing.append("model")
|
missing.append("model")
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(f"Incomplete LLM config: missing {', '.join(missing)}")
|
raise ValueError(f"LLM 配置不完整,缺少: {', '.join(missing)}")
|
||||||
|
|
||||||
return OpenAICompatibleLlmClient(
|
return OpenAICompatibleLlmClient(
|
||||||
base_url=actual_base_url,
|
base_url=actual_base_url,
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
"""OpenAI-compatible HTTP LLM client.
|
"""OpenAI-compatible HTTP LLM client。
|
||||||
|
|
||||||
The client targets providers exposing a `/chat/completions` endpoint with
|
该 client 面向暴露 `/chat/completions` 的模型服务,并使用 OpenAI 风格的
|
||||||
OpenAI-style request and response shapes. It intentionally uses only the Python
|
请求/响应结构。实现只依赖 Python 标准库,便于控制运行时依赖体积。
|
||||||
standard library so the runtime can stay dependency-light.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -28,6 +27,8 @@ JsonTransport = Callable[[str, dict[str, str], dict[str, Any], float], dict[str,
|
|||||||
|
|
||||||
|
|
||||||
class OpenAICompatibleLlmClient:
|
class OpenAICompatibleLlmClient:
|
||||||
|
"""通过 OpenAI-compatible HTTP 接口获取结构化 LLM 输出。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@ -38,12 +39,13 @@ class OpenAICompatibleLlmClient:
|
|||||||
temperature: float = 0,
|
temperature: float = 0,
|
||||||
transport: JsonTransport | None = None,
|
transport: JsonTransport | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""保存连接参数、模型参数和可替换的 HTTP transport。"""
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise ValueError("LLM base_url is required")
|
raise ValueError("必须配置 LLM base_url")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise ValueError("LLM api_key is required")
|
raise ValueError("必须配置 LLM api_key")
|
||||||
if not model:
|
if not model:
|
||||||
raise ValueError("LLM model is required")
|
raise ValueError("必须配置 LLM model")
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.model = model
|
self.model = model
|
||||||
@ -52,6 +54,7 @@ class OpenAICompatibleLlmClient:
|
|||||||
self.transport = transport or _default_transport
|
self.transport = transport or _default_transport
|
||||||
|
|
||||||
def understand_request(self, text: str) -> LlmIntentResult:
|
def understand_request(self, text: str) -> LlmIntentResult:
|
||||||
|
"""调用 LLM 识别用户意图。"""
|
||||||
payload = self._complete_json(INTENT_PROMPT, {"user_text": text})
|
payload = self._complete_json(INTENT_PROMPT, {"user_text": text})
|
||||||
return LlmIntentResult(
|
return LlmIntentResult(
|
||||||
intent=_string(payload, "intent", "deploy"), # type: ignore[arg-type]
|
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:
|
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
|
||||||
|
"""调用 LLM 抽取参数,并避免把敏感值发送进 prompt。"""
|
||||||
original_base = dict(base_params or {})
|
original_base = dict(base_params or {})
|
||||||
safe_base = _redact_sensitive(original_base)
|
safe_base = _redact_sensitive(original_base)
|
||||||
payload = self._complete_json(
|
payload = self._complete_json(
|
||||||
@ -102,6 +106,7 @@ class OpenAICompatibleLlmClient:
|
|||||||
intent: str,
|
intent: str,
|
||||||
strategy: ExecutionStrategy,
|
strategy: ExecutionStrategy,
|
||||||
) -> LlmDeployPlan:
|
) -> LlmDeployPlan:
|
||||||
|
"""调用 LLM 生成部署计划。"""
|
||||||
payload = self._complete_json(
|
payload = self._complete_json(
|
||||||
PLAN_PROMPT,
|
PLAN_PROMPT,
|
||||||
{
|
{
|
||||||
@ -115,7 +120,7 @@ class OpenAICompatibleLlmClient:
|
|||||||
)
|
)
|
||||||
planned_actions = _string_list(payload.get("planned_actions")) or list(GLOBAL_ACTION_SEQUENCE)
|
planned_actions = _string_list(payload.get("planned_actions")) or list(GLOBAL_ACTION_SEQUENCE)
|
||||||
return LlmDeployPlan(
|
return LlmDeployPlan(
|
||||||
summary=_string(payload, "summary", "PAM deployment plan"),
|
summary=_string(payload, "summary", "PAM 部署计划"),
|
||||||
risk_notes=_string_list(payload.get("risk_notes")),
|
risk_notes=_string_list(payload.get("risk_notes")),
|
||||||
planned_actions=planned_actions,
|
planned_actions=planned_actions,
|
||||||
requires_confirmation=bool(payload.get("requires_confirmation", True)),
|
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]:
|
def _complete_json(self, instruction: str, input_payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""发送 chat/completions 请求,并解析 JSON 对象响应。"""
|
||||||
request_payload = {
|
request_payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"temperature": self.temperature,
|
"temperature": self.temperature,
|
||||||
@ -149,7 +155,7 @@ class OpenAICompatibleLlmClient:
|
|||||||
content = _message_content(response)
|
content = _message_content(response)
|
||||||
parsed = _loads_json_object(content)
|
parsed = _loads_json_object(content)
|
||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
raise ValueError("LLM response must be a JSON object")
|
raise ValueError("LLM 响应必须是 JSON object")
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@ -159,6 +165,7 @@ def _default_transport(
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
timeout_sec: float,
|
timeout_sec: float,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""使用标准库 urllib 发送 JSON POST 请求。"""
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=json.dumps(payload).encode("utf-8"),
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
@ -169,11 +176,12 @@ def _default_transport(
|
|||||||
raw = response.read().decode("utf-8")
|
raw = response.read().decode("utf-8")
|
||||||
decoded = json.loads(raw)
|
decoded = json.loads(raw)
|
||||||
if not isinstance(decoded, dict):
|
if not isinstance(decoded, dict):
|
||||||
raise ValueError("LLM HTTP response must be a JSON object")
|
raise ValueError("LLM HTTP 响应必须是 JSON object")
|
||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
def _chat_completions_url(base_url: str) -> str:
|
def _chat_completions_url(base_url: str) -> str:
|
||||||
|
"""把 base_url 规范化为 chat/completions endpoint。"""
|
||||||
clean = base_url.rstrip("/")
|
clean = base_url.rstrip("/")
|
||||||
if clean.endswith("/chat/completions"):
|
if clean.endswith("/chat/completions"):
|
||||||
return clean
|
return clean
|
||||||
@ -181,10 +189,11 @@ def _chat_completions_url(base_url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _message_content(response: dict[str, Any]) -> Any:
|
def _message_content(response: dict[str, Any]) -> Any:
|
||||||
|
"""从 OpenAI-compatible 响应中提取 message.content。"""
|
||||||
try:
|
try:
|
||||||
content = response["choices"][0]["message"]["content"]
|
content = response["choices"][0]["message"]["content"]
|
||||||
except (KeyError, IndexError, TypeError) as exc:
|
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):
|
if isinstance(content, list):
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for item in content:
|
for item in content:
|
||||||
@ -197,14 +206,16 @@ def _message_content(response: dict[str, Any]) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def _loads_json_object(content: Any) -> Any:
|
def _loads_json_object(content: Any) -> Any:
|
||||||
|
"""把 message.content 解析为 JSON 对象。"""
|
||||||
if isinstance(content, dict):
|
if isinstance(content, dict):
|
||||||
return content
|
return content
|
||||||
if not isinstance(content, str):
|
if not isinstance(content, str):
|
||||||
raise ValueError("LLM message content must be JSON text")
|
raise ValueError("LLM message content 必须是 JSON 文本")
|
||||||
return json.loads(content)
|
return json.loads(content)
|
||||||
|
|
||||||
|
|
||||||
def _redact_sensitive(value: Any) -> Any:
|
def _redact_sensitive(value: Any) -> Any:
|
||||||
|
"""递归脱敏 prompt 输入中的敏感字段。"""
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
redacted: dict[str, Any] = {}
|
redacted: dict[str, Any] = {}
|
||||||
for key, item in value.items():
|
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:
|
def _string(payload: dict[str, Any], key: str, default: str) -> str:
|
||||||
|
"""安全读取字符串字段。"""
|
||||||
value = payload.get(key, default)
|
value = payload.get(key, default)
|
||||||
return str(value) if value is not None else default
|
return str(value) if value is not None else default
|
||||||
|
|
||||||
|
|
||||||
def _float(payload: dict[str, Any], key: str, default: float) -> float:
|
def _float(payload: dict[str, Any], key: str, default: float) -> float:
|
||||||
|
"""安全读取浮点数字段。"""
|
||||||
try:
|
try:
|
||||||
return float(payload.get(key, default))
|
return float(payload.get(key, default))
|
||||||
except (TypeError, ValueError):
|
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]:
|
def _dict(value: Any) -> dict[str, Any]:
|
||||||
|
"""确保返回 dict,非法值降级为空 dict。"""
|
||||||
return value if isinstance(value, dict) else {}
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def _string_list(value: Any) -> list[str]:
|
def _string_list(value: Any) -> list[str]:
|
||||||
|
"""确保返回字符串列表。"""
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [str(item) for item in value]
|
return [str(item) for item in value]
|
||||||
if value in (None, ""):
|
if value in (None, ""):
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Prompts for structured PAM deployment planning."""
|
"""用于 PAM 部署结构化理解和规划的 LLM 提示词。"""
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是 PAM 智能部署 Agent 的结构化理解与规划组件。
|
SYSTEM_PROMPT = """你是 PAM 智能部署 Agent 的结构化理解与规划组件。
|
||||||
|
|
||||||
|
|||||||
@ -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
|
真实 LLM client 需要实现相同方法。
|
||||||
client should implement the same methods.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -45,7 +44,10 @@ KEY_ALIASES = {
|
|||||||
|
|
||||||
|
|
||||||
class RuleBasedLlmClient:
|
class RuleBasedLlmClient:
|
||||||
|
"""基于规则的轻量 LLM client fallback。"""
|
||||||
|
|
||||||
def understand_request(self, text: str) -> LlmIntentResult:
|
def understand_request(self, text: str) -> LlmIntentResult:
|
||||||
|
"""用关键词规则识别用户意图和执行策略偏好。"""
|
||||||
lowered = text.lower()
|
lowered = text.lower()
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
intent = "deploy"
|
intent = "deploy"
|
||||||
@ -87,6 +89,7 @@ class RuleBasedLlmClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
|
def extract_params(self, text: str, base_params: dict[str, Any] | None = None) -> LlmParamResult:
|
||||||
|
"""从 key=value、中文短语和 IP 地址中抽取参数。"""
|
||||||
params = dict(base_params or {})
|
params = dict(base_params or {})
|
||||||
params.update(self._extract_key_values(text))
|
params.update(self._extract_key_values(text))
|
||||||
params.update(self._extract_chinese_patterns(text))
|
params.update(self._extract_chinese_patterns(text))
|
||||||
@ -112,6 +115,7 @@ class RuleBasedLlmClient:
|
|||||||
intent: str,
|
intent: str,
|
||||||
strategy: ExecutionStrategy,
|
strategy: ExecutionStrategy,
|
||||||
) -> LlmDeployPlan:
|
) -> LlmDeployPlan:
|
||||||
|
"""生成确定性的部署计划和风险提示。"""
|
||||||
if strategy == "hybrid_node_mcp":
|
if strategy == "hybrid_node_mcp":
|
||||||
strategy_text = "PAM_HOME 使用脚本 action,PAM_NODE 使用 MCP"
|
strategy_text = "PAM_HOME 使用脚本 action,PAM_NODE 使用 MCP"
|
||||||
elif strategy == "script_only":
|
elif strategy == "script_only":
|
||||||
@ -142,6 +146,7 @@ class RuleBasedLlmClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _extract_key_values(self, text: str) -> dict[str, str]:
|
def _extract_key_values(self, text: str) -> dict[str, str]:
|
||||||
|
"""抽取 KEY=VALUE 形式的参数。"""
|
||||||
params: dict[str, str] = {}
|
params: dict[str, str] = {}
|
||||||
for match in re.finditer(r"([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([^\s,;]+)", text):
|
for match in re.finditer(r"([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([^\s,;]+)", text):
|
||||||
raw_key, value = match.groups()
|
raw_key, value = match.groups()
|
||||||
@ -151,6 +156,7 @@ class RuleBasedLlmClient:
|
|||||||
return params
|
return params
|
||||||
|
|
||||||
def _extract_chinese_patterns(self, text: str) -> dict[str, str]:
|
def _extract_chinese_patterns(self, text: str) -> dict[str, str]:
|
||||||
|
"""抽取常见中文描述中的部署参数。"""
|
||||||
patterns = {
|
patterns = {
|
||||||
"AIRPORT_CODE": r"(?:机场|三字码)\s*[::]?\s*([A-Z]{3})",
|
"AIRPORT_CODE": r"(?:机场|三字码)\s*[::]?\s*([A-Z]{3})",
|
||||||
"APP_NAME": r"(?:应用|应用名)\s*[::]?\s*([A-Za-z0-9_.-]+)",
|
"APP_NAME": r"(?:应用|应用名)\s*[::]?\s*([A-Za-z0-9_.-]+)",
|
||||||
@ -164,4 +170,3 @@ class RuleBasedLlmClient:
|
|||||||
if match:
|
if match:
|
||||||
params[key] = match.group(1)
|
params[key] = match.group(1)
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Validation and guardrails for LLM structured outputs."""
|
"""LLM 结构化输出的校验和安全护栏。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
def validate_intent_result(result: LlmIntentResult) -> None:
|
||||||
|
"""校验意图识别结果是否合法。"""
|
||||||
if result.intent not in VALID_INTENTS:
|
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:
|
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:
|
def validate_deploy_plan(plan: LlmDeployPlan) -> None:
|
||||||
|
"""校验部署计划中的 action 和文本安全性。"""
|
||||||
invalid = [action for action in plan.planned_actions if action not in ALLOWED_ACTIONS]
|
invalid = [action for action in plan.planned_actions if action not in ALLOWED_ACTIONS]
|
||||||
if invalid:
|
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])
|
combined_text = "\n".join([plan.summary, *plan.risk_notes])
|
||||||
lowered = combined_text.lower()
|
lowered = combined_text.lower()
|
||||||
forbidden = [item for item in FORBIDDEN_TEXT if item.lower() in lowered]
|
forbidden = [item for item in FORBIDDEN_TEXT if item.lower() in lowered]
|
||||||
if forbidden:
|
if forbidden:
|
||||||
raise ValueError(f"Plan contains forbidden executable text: {', '.join(forbidden)}")
|
raise ValueError(f"计划包含禁止出现的可执行文本: {', '.join(forbidden)}")
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
"""MCP client adapters.
|
"""MCP client 适配器。
|
||||||
|
|
||||||
The Agent only needs a synchronous `call_tool(name, arguments)` surface. This
|
Agent 只依赖同步的 `call_tool(name, arguments)` 接口。本模块把普通
|
||||||
module adapts simple callables or SDK-like sessions to that surface without
|
callable 或 SDK session 适配成这个接口,避免业务代码绑定具体 MCP SDK。
|
||||||
forcing the rest of the codebase to import a concrete MCP SDK.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -16,16 +15,17 @@ from typing import Any
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class McpClientConfig:
|
class McpClientConfig:
|
||||||
"""Configuration needed after a real MCP session has been created."""
|
"""真实 MCP session 建立后需要传给 runner 的配置。"""
|
||||||
|
|
||||||
server_name: str = "pam-node"
|
server_name: str = "pam-node"
|
||||||
tool_names: dict[str, str] = field(default_factory=dict)
|
tool_names: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_mapping(cls, payload: dict[str, Any]) -> "McpClientConfig":
|
def from_mapping(cls, payload: dict[str, Any]) -> "McpClientConfig":
|
||||||
|
"""从 JSON 字典构造 MCP client 配置。"""
|
||||||
tool_names = payload.get("tool_names") or payload.get("tools") or {}
|
tool_names = payload.get("tool_names") or payload.get("tools") or {}
|
||||||
if not isinstance(tool_names, dict):
|
if not isinstance(tool_names, dict):
|
||||||
raise ValueError("MCP tool_names must be an object")
|
raise ValueError("MCP tool_names 必须是 JSON object")
|
||||||
return cls(
|
return cls(
|
||||||
server_name=str(payload.get("server_name", "pam-node")),
|
server_name=str(payload.get("server_name", "pam-node")),
|
||||||
tool_names={str(key): str(value) for key, value in tool_names.items()},
|
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:
|
def load_mcp_client_config(path: str | Path) -> McpClientConfig:
|
||||||
|
"""读取 MCP client JSON 配置文件。"""
|
||||||
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
if not isinstance(payload, dict):
|
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)
|
return McpClientConfig.from_mapping(payload)
|
||||||
|
|
||||||
|
|
||||||
class FunctionMcpToolClient:
|
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:
|
def __init__(self, caller: Callable[[str, dict[str, Any]], Any]) -> None:
|
||||||
|
"""保存实际执行工具调用的函数。"""
|
||||||
self.caller = caller
|
self.caller = caller
|
||||||
|
|
||||||
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
||||||
|
"""调用底层函数并返回原始结果。"""
|
||||||
return self.caller(tool_name, arguments)
|
return self.caller(tool_name, arguments)
|
||||||
|
|
||||||
|
|
||||||
class SessionMcpToolClient:
|
class SessionMcpToolClient:
|
||||||
"""Adapt SDK-like sessions exposing `call_tool`.
|
"""适配暴露 `call_tool` 的 MCP SDK session。
|
||||||
|
|
||||||
The adapter accepts common result shapes:
|
适配器接受常见返回形态:
|
||||||
|
|
||||||
- raw dict/list/string
|
- 原始 dict/list/string
|
||||||
- object with `structuredContent`
|
- 带有 `structuredContent` 的对象
|
||||||
- object with `content`, where text content may contain JSON
|
- 带有 `content` 的对象,其中 text 内容可能是 JSON
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session: Any) -> None:
|
def __init__(self, session: Any) -> None:
|
||||||
|
"""校验并保存 MCP SDK session。"""
|
||||||
if not hasattr(session, "call_tool"):
|
if not hasattr(session, "call_tool"):
|
||||||
raise TypeError("MCP session must expose call_tool")
|
raise TypeError("MCP session 必须暴露 call_tool 方法")
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
||||||
|
"""调用 SDK session,并把 SDK 返回值归一化。"""
|
||||||
result = self.session.call_tool(tool_name, arguments)
|
result = self.session.call_tool(tool_name, arguments)
|
||||||
return normalize_mcp_sdk_result(result)
|
return normalize_mcp_sdk_result(result)
|
||||||
|
|
||||||
|
|
||||||
def normalize_mcp_sdk_result(result: Any) -> Any:
|
def normalize_mcp_sdk_result(result: Any) -> Any:
|
||||||
|
"""把常见 MCP SDK 返回结构归一化成 dict/list/string。"""
|
||||||
if hasattr(result, "structuredContent"):
|
if hasattr(result, "structuredContent"):
|
||||||
structured = getattr(result, "structuredContent")
|
structured = getattr(result, "structuredContent")
|
||||||
if structured is not None:
|
if structured is not None:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Runner wrapper for PAM_NODE MCP tools."""
|
"""PAM_NODE MCP 工具的 action runner 封装。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -9,7 +9,10 @@ from .output_parser import parse_mcp_result
|
|||||||
|
|
||||||
|
|
||||||
class McpToolClient(Protocol):
|
class McpToolClient(Protocol):
|
||||||
|
"""MCP 工具客户端需要实现的最小同步接口。"""
|
||||||
|
|
||||||
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
|
||||||
|
"""调用指定 MCP tool,并返回工具原始输出。"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@ -28,11 +31,14 @@ DEFAULT_NODE_MCP_TOOLS = {
|
|||||||
|
|
||||||
|
|
||||||
class McpActionRunner:
|
class McpActionRunner:
|
||||||
|
"""把 Agent action 转换为 MCP tool 调用。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client: McpToolClient | None = None,
|
client: McpToolClient | None = None,
|
||||||
tool_names: dict[str, str] | None = None,
|
tool_names: dict[str, str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""保存 MCP client 和 action 到 tool name 的映射。"""
|
||||||
self.client = client
|
self.client = client
|
||||||
self.tool_names = tool_names or DEFAULT_NODE_MCP_TOOLS.copy()
|
self.tool_names = tool_names or DEFAULT_NODE_MCP_TOOLS.copy()
|
||||||
|
|
||||||
@ -46,11 +52,12 @@ class McpActionRunner:
|
|||||||
stop_first: bool = False,
|
stop_first: bool = False,
|
||||||
**_: Any,
|
**_: Any,
|
||||||
) -> ActionResult:
|
) -> ActionResult:
|
||||||
|
"""执行一个 PAM_NODE action,并归一化为 ActionResult。"""
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
raise RuntimeError("MCP client is not configured")
|
raise RuntimeError("尚未配置 MCP client")
|
||||||
tool_name = self.tool_names.get(action)
|
tool_name = self.tool_names.get(action)
|
||||||
if not tool_name:
|
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(
|
arguments = self._build_arguments(
|
||||||
action,
|
action,
|
||||||
params=params,
|
params=params,
|
||||||
@ -60,7 +67,7 @@ class McpActionRunner:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = self.client.call_tool(tool_name, arguments)
|
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, {}, ok=False, tool_name=tool_name, error=str(exc))
|
||||||
return parse_mcp_result(action, payload, ok=True, tool_name=tool_name)
|
return parse_mcp_result(action, payload, ok=True, tool_name=tool_name)
|
||||||
|
|
||||||
@ -73,6 +80,7 @@ class McpActionRunner:
|
|||||||
hash_code: str | None,
|
hash_code: str | None,
|
||||||
stop_first: bool,
|
stop_first: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""把 Agent 参数转换为 MCP tool 所需的入参。"""
|
||||||
arguments = {
|
arguments = {
|
||||||
"homeBaseUrl": params.get("HOME_BASE_URL"),
|
"homeBaseUrl": params.get("HOME_BASE_URL"),
|
||||||
"airportCode": params.get("AIRPORT_CODE"),
|
"airportCode": params.get("AIRPORT_CODE"),
|
||||||
@ -90,4 +98,3 @@ class McpActionRunner:
|
|||||||
if action == "rollback-ip":
|
if action == "rollback-ip":
|
||||||
arguments["stopFirst"] = stop_first
|
arguments["stopFirst"] = stop_first
|
||||||
return {key: value for key, value in arguments.items() if value not in (None, "")}
|
return {key: value for key, value in arguments.items() if value not in (None, "")}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Shared dataclasses for the PAM deploy agent."""
|
"""PAM 部署 Agent 共享数据模型。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -14,6 +14,8 @@ StrategyPreference = Literal["hybrid_node_mcp", "script_only", "fake", "未指
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ActionResult:
|
class ActionResult:
|
||||||
|
"""单个 action 的统一执行结果。"""
|
||||||
|
|
||||||
action: str
|
action: str
|
||||||
backend: BackendName
|
backend: BackendName
|
||||||
ok: bool
|
ok: bool
|
||||||
@ -28,6 +30,8 @@ class ActionResult:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SkillPolicy:
|
class SkillPolicy:
|
||||||
|
"""从 Skill 文档提取出的部署策略约束。"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
source_path: str
|
source_path: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
@ -51,6 +55,8 @@ class SkillPolicy:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class LlmIntentResult:
|
class LlmIntentResult:
|
||||||
|
"""LLM 意图识别结果。"""
|
||||||
|
|
||||||
intent: IntentName
|
intent: IntentName
|
||||||
mode_preference: ModePreference = "未指定"
|
mode_preference: ModePreference = "未指定"
|
||||||
strategy_preference: StrategyPreference = "未指定"
|
strategy_preference: StrategyPreference = "未指定"
|
||||||
@ -62,6 +68,8 @@ class LlmIntentResult:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class LlmParamResult:
|
class LlmParamResult:
|
||||||
|
"""LLM 参数抽取结果。"""
|
||||||
|
|
||||||
extracted_params: dict[str, Any] = field(default_factory=dict)
|
extracted_params: dict[str, Any] = field(default_factory=dict)
|
||||||
extracted_control: 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)
|
missing_required_params: list[str] = field(default_factory=list)
|
||||||
@ -71,6 +79,8 @@ class LlmParamResult:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class LlmDeployPlan:
|
class LlmDeployPlan:
|
||||||
|
"""LLM 生成的部署计划。"""
|
||||||
|
|
||||||
summary: str
|
summary: str
|
||||||
risk_notes: list[str] = field(default_factory=list)
|
risk_notes: list[str] = field(default_factory=list)
|
||||||
planned_actions: list[str] = field(default_factory=list)
|
planned_actions: list[str] = field(default_factory=list)
|
||||||
@ -80,6 +90,8 @@ class LlmDeployPlan:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AgentState:
|
class AgentState:
|
||||||
|
"""一次部署运行的完整状态,可序列化到 checkpoint。"""
|
||||||
|
|
||||||
run_id: str
|
run_id: str
|
||||||
params: dict[str, Any]
|
params: dict[str, Any]
|
||||||
execution_strategy: ExecutionStrategy
|
execution_strategy: ExecutionStrategy
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Normalize script stdout and MCP tool returns into ActionResult objects."""
|
"""把脚本 stdout 和 MCP tool 返回值归一化为 ActionResult。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
def redact_text(text: str) -> str:
|
||||||
|
"""对文本中的敏感字段值进行脱敏。"""
|
||||||
redacted = text
|
redacted = text
|
||||||
for key in SENSITIVE_KEYS:
|
for key in SENSITIVE_KEYS:
|
||||||
redacted = re.sub(
|
redacted = re.sub(
|
||||||
@ -26,6 +27,7 @@ def redact_text(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def parse_key_values(text: str) -> dict[str, Any]:
|
def parse_key_values(text: str) -> dict[str, Any]:
|
||||||
|
"""解析脚本输出中的 KEY=VALUE 行,重复 IP 会聚合为列表。"""
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
for raw_line in text.splitlines():
|
for raw_line in text.splitlines():
|
||||||
line = raw_line.strip()
|
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]:
|
def normalize_mcp_values(payload: Any) -> dict[str, Any]:
|
||||||
|
"""把 MCP 返回值归一化为脚本兼容的字段名。"""
|
||||||
if isinstance(payload, str):
|
if isinstance(payload, str):
|
||||||
try:
|
try:
|
||||||
payload = json.loads(payload)
|
payload = json.loads(payload)
|
||||||
@ -75,6 +78,7 @@ def parse_script_result(
|
|||||||
backend: BackendName = "script",
|
backend: BackendName = "script",
|
||||||
tool_name: str = "",
|
tool_name: str = "",
|
||||||
) -> ActionResult:
|
) -> ActionResult:
|
||||||
|
"""解析脚本执行结果,并识别待人工确认标记。"""
|
||||||
raw_output = redact_text("\n".join(part for part in (stdout, stderr) if part))
|
raw_output = redact_text("\n".join(part for part in (stdout, stderr) if part))
|
||||||
values = parse_key_values(stdout)
|
values = parse_key_values(stdout)
|
||||||
pending = PENDING_CONFIRMATION_RE.search(stdout) or PENDING_CONFIRMATION_RE.search(stderr)
|
pending = PENDING_CONFIRMATION_RE.search(stdout) or PENDING_CONFIRMATION_RE.search(stderr)
|
||||||
@ -105,6 +109,7 @@ def parse_mcp_result(
|
|||||||
tool_name: str = "",
|
tool_name: str = "",
|
||||||
error: str = "",
|
error: str = "",
|
||||||
) -> ActionResult:
|
) -> ActionResult:
|
||||||
|
"""解析 MCP tool 返回值,并包装为统一 ActionResult。"""
|
||||||
values = normalize_mcp_values(payload)
|
values = normalize_mcp_values(payload)
|
||||||
raw_output = redact_text(json.dumps(payload, ensure_ascii=False, default=str))
|
raw_output = redact_text(json.dumps(payload, ensure_ascii=False, default=str))
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -117,11 +122,12 @@ def parse_mcp_result(
|
|||||||
stdout=raw_output if ok else "",
|
stdout=raw_output if ok else "",
|
||||||
stderr=redact_text(error),
|
stderr=redact_text(error),
|
||||||
raw_output=raw_output,
|
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:
|
def _summarize_error(stderr: str, stdout: str, pending: str) -> str:
|
||||||
|
"""从 stderr/stdout/确认标记中提取简短错误摘要。"""
|
||||||
if pending:
|
if pending:
|
||||||
return pending
|
return pending
|
||||||
for text in (stderr, stdout):
|
for text in (stderr, stdout):
|
||||||
@ -129,5 +135,4 @@ def _summarize_error(stderr: str, stdout: str, pending: str) -> str:
|
|||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped:
|
if stripped:
|
||||||
return redact_text(stripped)
|
return redact_text(stripped)
|
||||||
return "Action failed"
|
return "action 执行失败"
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Load deploy parameters from JSON or config.txt style files."""
|
"""从 JSON 或 config.txt 风格文件读取部署参数。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
def load_params_file(path: str | Path) -> dict[str, Any]:
|
def load_params_file(path: str | Path) -> dict[str, Any]:
|
||||||
|
"""自动识别 JSON/config.txt 格式,并返回参数字典。"""
|
||||||
config_path = Path(path)
|
config_path = Path(path)
|
||||||
text = config_path.read_text(encoding="utf-8")
|
text = config_path.read_text(encoding="utf-8")
|
||||||
stripped = text.lstrip()
|
stripped = text.lstrip()
|
||||||
@ -24,4 +25,3 @@ def load_params_file(path: str | Path) -> dict[str, Any]:
|
|||||||
key, value = line.split("=", 1)
|
key, value = line.split("=", 1)
|
||||||
values[key.strip()] = value.strip()
|
values[key.strip()] = value.strip()
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Subprocess runner for deploy.sh and deploy.ps1 action calls."""
|
"""通过子进程执行 deploy.sh / deploy.ps1 action。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -11,7 +11,10 @@ from .output_parser import parse_script_result
|
|||||||
|
|
||||||
|
|
||||||
class ScriptActionRunner:
|
class ScriptActionRunner:
|
||||||
|
"""脚本 action runner,负责构造命令、执行脚本并解析结果。"""
|
||||||
|
|
||||||
def __init__(self, script_base_dir: str | Path = "doc_scripts") -> None:
|
def __init__(self, script_base_dir: str | Path = "doc_scripts") -> None:
|
||||||
|
"""保存脚本所在目录。"""
|
||||||
self.script_base_dir = Path(script_base_dir)
|
self.script_base_dir = Path(script_base_dir)
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
@ -27,6 +30,7 @@ class ScriptActionRunner:
|
|||||||
trace_file_path: str | None = None,
|
trace_file_path: str | None = None,
|
||||||
timeout_sec: int | None = None,
|
timeout_sec: int | None = None,
|
||||||
) -> ActionResult:
|
) -> ActionResult:
|
||||||
|
"""执行一个脚本 action,并返回统一 ActionResult。"""
|
||||||
command = self.build_command(
|
command = self.build_command(
|
||||||
action,
|
action,
|
||||||
script_entry=script_entry,
|
script_entry=script_entry,
|
||||||
@ -64,6 +68,7 @@ class ScriptActionRunner:
|
|||||||
stop_first: bool = False,
|
stop_first: bool = False,
|
||||||
trace_file_path: str | None = None,
|
trace_file_path: str | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
"""根据脚本类型构造 action 命令行参数。"""
|
||||||
if script_entry == "deploy.sh":
|
if script_entry == "deploy.sh":
|
||||||
command = [
|
command = [
|
||||||
"bash",
|
"bash",
|
||||||
@ -101,14 +106,14 @@ class ScriptActionRunner:
|
|||||||
command.append("-RollbackStopFirst")
|
command.append("-RollbackStopFirst")
|
||||||
return command
|
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:
|
def select_script_entry(os_name: str | None = None) -> str:
|
||||||
|
"""根据操作系统选择默认脚本入口。"""
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
name = (os_name or platform.system()).lower()
|
name = (os_name or platform.system()).lower()
|
||||||
if "windows" in name:
|
if "windows" in name:
|
||||||
return "deploy.ps1"
|
return "deploy.ps1"
|
||||||
return "deploy.sh"
|
return "deploy.sh"
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Load the PAM deploy Skill document into a compact policy object."""
|
"""把 PAM 部署 Skill 文档加载为简化策略对象。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -15,6 +15,7 @@ from .models import SkillPolicy
|
|||||||
|
|
||||||
|
|
||||||
def load_skill_policy(path: str | Path) -> SkillPolicy:
|
def load_skill_policy(path: str | Path) -> SkillPolicy:
|
||||||
|
"""读取 Skill markdown 头部信息,并填充 action/参数策略。"""
|
||||||
skill_path = Path(path)
|
skill_path = Path(path)
|
||||||
text = skill_path.read_text(encoding="utf-8")
|
text = skill_path.read_text(encoding="utf-8")
|
||||||
name = "pam-auto-deply"
|
name = "pam-auto-deply"
|
||||||
@ -39,4 +40,3 @@ def load_skill_policy(path: str | Path) -> SkillPolicy:
|
|||||||
action_sequence=GLOBAL_ACTION_SEQUENCE,
|
action_sequence=GLOBAL_ACTION_SEQUENCE,
|
||||||
ip_action_sequence=IP_ACTION_SEQUENCE,
|
ip_action_sequence=IP_ACTION_SEQUENCE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ def test_build_graph_or_none_without_langgraph_is_safe():
|
|||||||
def test_build_langgraph_error_without_dependency_is_clear():
|
def test_build_langgraph_error_without_dependency_is_clear():
|
||||||
if importlib.util.find_spec("langgraph"):
|
if importlib.util.find_spec("langgraph"):
|
||||||
pytest.skip("langgraph installed")
|
pytest.skip("langgraph installed")
|
||||||
with pytest.raises(RuntimeError, match="langgraph is not installed"):
|
with pytest.raises(RuntimeError, match="未安装 langgraph"):
|
||||||
build_langgraph()
|
build_langgraph()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -69,7 +69,7 @@ def test_plan_guardrails_reject_executable_text():
|
|||||||
try:
|
try:
|
||||||
validate_deploy_plan(plan)
|
validate_deploy_plan(plan)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
assert "forbidden" in str(exc)
|
assert "禁止" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError("expected guardrail failure")
|
raise AssertionError("expected guardrail failure")
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user