LLM action 结果分析不再传 state_summary

调整了 agent.py 和 LLM client 协议/实现。
现在只传当前 action 的结构化结果和必要诊断日志,避免历史运行态影响判断。
提示词和文档也已同步说明。

verify-ip 增加健康检查重试
默认 VERIFY_INTERVAL_SEC=10、VERIFY_MAX_ATTEMPTS=12,约 2 分钟。
verify-ip 未通过但未达到最大次数时,会播报进度、保存 checkpoint,并继续从当前 verify-ip 重试,不会进入 download-log。
参数已加入 config.txt.example、脚本配置读取、README、打包 README、Skill 文档和流程图。
This commit is contained in:
dark 2026-06-04 16:57:16 +08:00
parent e572a26e6f
commit 4250a7b221
20 changed files with 229 additions and 53 deletions

View File

@ -91,7 +91,7 @@ packaging/
- 支持通过 `--llm-action-analysis-prompt-file``PAM_LLM_ACTION_ANALYSIS_PROMPT_FILE` 或 chat 内 `llm config action_analysis_prompt_file=...` 自定义 action 审核提示词。
- 增加统一运行日志,默认写入 `logs/pam_deploy_agent.log`,覆盖 CLI/chat、LLM 调用、action 路由、脚本/MCP 调用、LangGraph、checkpoint 等关键流程。
- chat 支持 `llm test [文本]`,可用当前 LLM client 做一次轻量调用,确认真实 LLM 或规则 fallback 是否正常加载。
- 添加基础测试,当前本地结果为 `66 passed, 3 skipped`。
- 添加基础测试,当前本地结果为 `67 passed, 3 skipped`。
未完成:
@ -300,13 +300,15 @@ PAM> resume
PAM> exit
```
`chat` 默认仍要求在会话内显式输入 `run`,并确认参数、目标 IP 范围和最终执行后才会执行 action。输入 `你好``hello` 这类问候不会触发 LLM/结构化分析;需要分析部署需求时可直接描述部署任务,或显式使用 `analyze <需求>`。每个 action 完成后都会自动进入一次 LLM/规则审核,并播报审核开始/结束;只有审核通过才会把 action 记为 completed如果审核建议停止或审核本身失败流程会暂停并输出建议等待用户决定是否 `resume` 重试当前 action。`poll-download-progress``poll-upgrade-progress` 每次只查询一次进度workflow 会按 `POLL_INTERVAL_SEC``DOWNLOAD_POLL_MAX_ATTEMPTS``UPGRADE_POLL_MAX_ATTEMPTS` 重复调用,并在每次返回后让 LLM/规则判断是否完成、播报进度;未完成时不会跳到下一个 action。逐 IP action 失败时也会暂停,修复外部环境后输入 `resume` 会从当前 action 重试;如果确实需要回滚,使用 `rollback [IP]` 显式执行。`llm test [文本]` 可测试当前 LLM client 是否可用。`--analyze-actions` 仅控制详细审核结果是否写入 `events`。执行中可按 `Ctrl+C` 中断chat 会保存当前 checkpoint 并把流程标记为 `user_interrupted``set KEY=VALUE``load params <路径>` 会把更新同步到当前运行 state、`config.txt` 和 checkpoint。`chat` 也支持 `--llm-base-url` / `--llm-api-key` / `--llm-model` / `--llm-action-analysis-prompt-file``--mcp-config``--analyze-actions`
`chat` 默认仍要求在会话内显式输入 `run`,并确认参数、目标 IP 范围和最终执行后才会执行 action。输入 `你好``hello` 这类问候不会触发 LLM/结构化分析;需要分析部署需求时可直接描述部署任务,或显式使用 `analyze <需求>`。每个 action 完成后都会自动进入一次 LLM/规则审核,并播报审核开始/结束;审核输入只包含当前 action 的结构化结果和必要诊断日志,不会把完整运行态 `state_summary` 交给大模型,避免跨步骤状态干扰判断;只有审核通过才会把 action 记为 completed如果审核建议停止或审核本身失败流程会暂停并输出建议等待用户决定是否 `resume` 重试当前 action。`poll-download-progress``poll-upgrade-progress` 每次只查询一次进度workflow 会按 `POLL_INTERVAL_SEC``DOWNLOAD_POLL_MAX_ATTEMPTS``UPGRADE_POLL_MAX_ATTEMPTS` 重复调用,并在每次返回后让 LLM/规则判断是否完成、播报进度;未完成时不会跳到下一个 action`verify-ip` 用于应用启动后的健康检查,失败时 workflow 会按 `VERIFY_INTERVAL_SEC` 重试,最多 `VERIFY_MAX_ATTEMPTS` 次;默认约每 10 秒一次、最多 12 次,仍未通过才暂停。逐 IP action 失败时也会暂停,修复外部环境后输入 `resume` 会从当前 action 重试;如果确实需要回滚,使用 `rollback [IP]` 显式执行。`llm test [文本]` 可测试当前 LLM client 是否可用。`--analyze-actions` 仅控制详细审核结果是否写入 `events`。执行中可按 `Ctrl+C` 中断chat 会保存当前 checkpoint 并把流程标记为 `user_interrupted``set KEY=VALUE``load params <路径>` 会把更新同步到当前运行 state、`config.txt` 和 checkpoint。`chat` 也支持 `--llm-base-url` / `--llm-api-key` / `--llm-model` / `--llm-action-analysis-prompt-file``--mcp-config``--analyze-actions`
进度查询相关参数:
重试和进度查询相关参数:
- `POLL_INTERVAL_SEC`:两次进度查询之间的等待秒数,默认 `2`
- `DOWNLOAD_POLL_MAX_ATTEMPTS`:云下载进度最大查询次数,默认 `60`
- `UPGRADE_POLL_MAX_ATTEMPTS`:单 IP 推送进度最大查询次数,默认 `600`
- `VERIFY_INTERVAL_SEC``verify-ip` 健康检查失败后的重试间隔秒数,默认 `10`
- `VERIFY_MAX_ATTEMPTS``verify-ip` 健康检查最大尝试次数,默认 `12`
## 日志

View File

@ -71,6 +71,8 @@ description: 面向 PAM HOME/NODE 的智能部署 Skill。由 Skill 负责理解
| `pollIntervalSec` | `POLL_INTERVAL_SEC` | 否 | 两次进度查询间隔,默认 `2` 秒 |
| `downloadPollMaxAttempts` | `DOWNLOAD_POLL_MAX_ATTEMPTS` | 否 | 云下载进度最大查询次数,默认 `60` |
| `upgradePollMaxAttempts` | `UPGRADE_POLL_MAX_ATTEMPTS` | 否 | 单 IP 推送进度最大查询次数,默认 `600` |
| `verifyIntervalSec` | `VERIFY_INTERVAL_SEC` | 否 | `verify-ip` 健康检查失败后的重试间隔,默认 `10` 秒 |
| `verifyMaxAttempts` | `VERIFY_MAX_ATTEMPTS` | 否 | `verify-ip` 健康检查最大尝试次数,默认 `12` |
### 3.2 运行控制参数
@ -164,6 +166,8 @@ description: 面向 PAM HOME/NODE 的智能部署 Skill。由 Skill 负责理解
- `POLL_INTERVAL_SEC`
- `DOWNLOAD_POLL_MAX_ATTEMPTS`
- `UPGRADE_POLL_MAX_ATTEMPTS`
- `VERIFY_INTERVAL_SEC`
- `VERIFY_MAX_ATTEMPTS`
- 命令行只传 action 级控制参数:
- `--action` / `-Action`
- `--ip` / `-Ip`
@ -173,7 +177,7 @@ description: 面向 PAM HOME/NODE 的智能部署 Skill。由 Skill 负责理解
- `client_secret` 等敏感字段不得通过命令行透传。
- 如果用户明确要求“不落地配置文件”,则本 Skill 不执行真实部署,只说明限制和原因。
- `traceFilePath` 不写入 `config.txt`,由 Agent 在运行时持有并应用。
- 进度查询间隔和最大次数写入 `config.txt`,由 Agent workflow 和脚本调试流程共同读取。
- 进度查询和健康检查重试参数写入 `config.txt`,由 Agent workflow 和脚本调试流程共同读取。
## 4. 主流程(硬约束)
@ -204,7 +208,7 @@ description: 面向 PAM HOME/NODE 的智能部署 Skill。由 Skill 负责理解
- `upgrade-ip`
- 重复调用 `poll-upgrade-progress` 单次查询进度;每次返回后交给 LLM/规则判断,直到推送完成、失败或达到最大查询次数
- `start-ip`
- `verify-ip`
- 重复调用 `verify-ip` 健康检查;`SUCCESS=false` 时按 `VERIFY_INTERVAL_SEC` 等待后重试,直到成功或达到 `VERIFY_MAX_ATTEMPTS`
- `download-log`
17. 汇总每台 IP 的结果。
18. 若 action 失败、LLM/规则审核要求停止,或出现 legacy `PENDING_AGENT_CONFIRMATION(...)`,暂停在当前 action 并输出建议。
@ -225,6 +229,7 @@ description: 面向 PAM HOME/NODE 的智能部署 Skill。由 Skill 负责理解
6. 若某步骤失败后需要进入提示、确认或分支流程,可按 `failurePauseSec` 等待。
7. 若某个间隔值为 `0`,表示该层级不等待,直接进入下一动作。
8. `poll-download-progress``poll-upgrade-progress` 的脚本 action 只执行一次进度查询;正式 workflow 的循环、checkpoint、LLM 判断和进度播报由 Agent Runtime 负责。
9. `verify-ip` 失败但未达到 `VERIFY_MAX_ATTEMPTS` 时,不进入 `download-log`,也不把当前 action 记为 completed正式 workflow 会播报健康检查进度、保存 checkpoint并按 `VERIFY_INTERVAL_SEC` 重试当前 action。
### 4.2 主流程中的强制确认点
@ -245,13 +250,14 @@ description: 面向 PAM HOME/NODE 的智能部署 Skill。由 Skill 负责理解
4. 在每个全局步骤失败后,立即告知用户失败阶段、失败原因和后续处理。
5. 在逐台 IP 处理时,必须告知当前正在处理哪一台 IP。
6. 在云下载和单 IP 推送进度查询阶段,每次 `poll-*` 返回后都必须汇报当前进度,不能静默等待完成。
7. 若执行耗时较长,必须按阶段持续播报,不能等全部结束后一次性汇总。
8. 若失败后建议回滚,必须明确告诉用户:
7. 在 `verify-ip` 健康检查阶段,每次未通过都必须播报当前检查次数、最大次数和返回信息,不能静默等待应用启动。
8. 若执行耗时较长,必须按阶段持续播报,不能等全部结束后一次性汇总。
9. 若失败后建议回滚,必须明确告诉用户:
- 哪一台 IP 失败
- 失败阶段
- 建议是否回滚
- 是否需要 `stopFirst`
9. 若当前处于 action 间隔等待中,也必须告诉用户等待时长和下一步动作。
10. 若当前处于 action 间隔等待中,也必须告诉用户等待时长和下一步动作。
建议的阶段播报格式:
@ -441,7 +447,7 @@ description: 面向 PAM HOME/NODE 的智能部署 Skill。由 Skill 负责理解
| 16.1 | 创建单 IP 推送任务 | `upgrade-ip --ip ...` | 返回 `RESULT=TASK_CREATED` | 暂停在当前 action修复后 `resume` 重试;需要回滚时显式执行 rollback |
| 16.2 | 查询单 IP 推送进度 | 重复调用单次 `poll-upgrade-progress --ip ...` | LLM/规则判断 `progress_complete=true`;或 `STEP=DONE` / `FINISH=true` / `MSG=success``RATE_OF_PROGRESS=100` | 暂停在当前 action修复后 `resume` 重试;需要回滚时显式执行 rollback |
| 16.3 | 启动单 IP | `start-ip --ip ...` | action 成功返回 | 暂停在当前 action修复后 `resume` 重试;需要回滚时显式执行 rollback |
| 16.4 | 校验单 IP | `verify-ip --ip ...` | 返回 `SUCCESS=true` | 暂停在当前 action,修复后 `resume` 重试;需要回滚时显式执行 rollback |
| 16.4 | 校验单 IP | 重复调用单次 `verify-ip --ip ...` | 返回 `SUCCESS=true` | `VERIFY_INTERVAL_SEC` 重试,达到 `VERIFY_MAX_ATTEMPTS` 后仍失败才暂停在当前 action需要回滚时显式执行 rollback |
| 16.5 | 下载日志 | `download-log --ip ...` | 返回 `LOG_FILE=...` | 记录日志下载失败,但不覆盖原主失败原因 |
| 17 | 汇总结果 | 汇总每台 IP 的阶段、失败原因、回滚状态、日志路径 | 报告内容完整 | 若汇总失败,至少保留原始 action 输出 |
| 18 | 失败暂停或显式回滚 | 失败后默认停在当前 action用户输入 `rollback [IP]` 后才执行回滚 | 用户明确要求回滚或修复后 `resume` | 未显式要求回滚时不自动回滚 |

View File

@ -12,3 +12,5 @@ LOG_NAME=app.log
POLL_INTERVAL_SEC=2
DOWNLOAD_POLL_MAX_ATTEMPTS=60
UPGRADE_POLL_MAX_ATTEMPTS=600
VERIFY_INTERVAL_SEC=10
VERIFY_MAX_ATTEMPTS=12

View File

@ -371,6 +371,8 @@ function Get-PamConfig {
'POLL_INTERVAL_SEC' { $config[$key] = $value }
'DOWNLOAD_POLL_MAX_ATTEMPTS' { $config[$key] = $value }
'UPGRADE_POLL_MAX_ATTEMPTS' { $config[$key] = $value }
'VERIFY_INTERVAL_SEC' { $config[$key] = $value }
'VERIFY_MAX_ATTEMPTS' { $config[$key] = $value }
}
}
} else {
@ -392,6 +394,8 @@ function Get-PamConfig {
POLL_INTERVAL_SEC = '2'
DOWNLOAD_POLL_MAX_ATTEMPTS = '60'
UPGRADE_POLL_MAX_ATTEMPTS = '600'
VERIFY_INTERVAL_SEC = '10'
VERIFY_MAX_ATTEMPTS = '12'
}
foreach ($name in $defaults.Keys) {

View File

@ -60,6 +60,8 @@ usage() {
POLL_INTERVAL_SEC
DOWNLOAD_POLL_MAX_ATTEMPTS
UPGRADE_POLL_MAX_ATTEMPTS
VERIFY_INTERVAL_SEC
VERIFY_MAX_ATTEMPTS
说明:
--action poll-download-progress 和 poll-upgrade-progress 只执行一次进度查询。
@ -352,6 +354,8 @@ set_defaults() {
: "${POLL_INTERVAL_SEC:=2}"
: "${DOWNLOAD_POLL_MAX_ATTEMPTS:=60}"
: "${UPGRADE_POLL_MAX_ATTEMPTS:=600}"
: "${VERIFY_INTERVAL_SEC:=10}"
: "${VERIFY_MAX_ATTEMPTS:=12}"
}
load_config() {
@ -376,7 +380,7 @@ load_config() {
value="$(strip_inline_comment "$value")"
case "$key" in
HOME_BASE_URL|CLIENT_ID|CLIENT_SECRET|AIRPORT_CODE|APP_NAME|MODULE_NAME|VERSION_NUMBER|ZIP_FILE_PATH|ACTION_TYPE|TIMEOUT|LOG_NAME|POLL_INTERVAL_SEC|DOWNLOAD_POLL_MAX_ATTEMPTS|UPGRADE_POLL_MAX_ATTEMPTS)
HOME_BASE_URL|CLIENT_ID|CLIENT_SECRET|AIRPORT_CODE|APP_NAME|MODULE_NAME|VERSION_NUMBER|ZIP_FILE_PATH|ACTION_TYPE|TIMEOUT|LOG_NAME|POLL_INTERVAL_SEC|DOWNLOAD_POLL_MAX_ATTEMPTS|UPGRADE_POLL_MAX_ATTEMPTS|VERIFY_INTERVAL_SEC|VERIFY_MAX_ATTEMPTS)
printf -v "$key" '%s' "$value"
;;
esac

View File

@ -93,7 +93,10 @@ flowchart TD
K1 -- 已完成 --> L[ip_action 节点执行 start-ip]
K1 -- 异常或超时 --> R
L --> M[ip_action 节点执行 verify-ip]
M --> N[ip_action 节点执行 download-log]
M --> M1{健康检查通过或达到最大次数}
M1 -- 未通过且未超时 --> M
M1 -- 已通过 --> N[ip_action 节点执行 download-log]
M1 -- 仍未通过且超时 --> R
N --> O{还有下一个 IP}
O -- 是 --> J
O -- 否 --> R[render_report 输出报告]
@ -115,8 +118,8 @@ flowchart LR
```mermaid
flowchart TD
A[action 执行完成] --> C[整理 ActionResult 和 AgentState 摘要]
C --> D[敏感字段脱敏并截断长日志]
A[action 执行完成] --> C[整理当前 ActionResult]
C --> D[敏感字段脱敏;仅在异常时附带必要诊断日志]
D --> E{真实 LLM 是否配置}
E -- 是 --> F[OpenAICompatibleLlmClient 输出结构化审核]
E -- 否 --> G[RuleBasedLlmClient 本地规则审核]
@ -134,11 +137,32 @@ flowchart TD
说明:
- 每个 action 完成后都会进入一次审核,不再依赖 `--analyze-actions` 开关。
- 审核输入只包含当前 action 的结构化结果和必要诊断日志,不再传入完整运行态 `state_summary`,避免历史状态干扰大模型判断。
- `--analyze-actions``llm action-analysis on` 只控制是否把详细审核结果写入 `events`
- 只有 action 执行成功且审核允许继续时,才会写入 `completed_global_steps``ip_states[ip].completed_steps`
- 如果审核建议停止或审核本身失败,当前 action 不会计入 completed`resume` 会重试当前 action。
- 如果审核本身失败,也会生成“停止继续”的审核结果并暂停流程,避免黑盒继续执行。
## verify-ip 健康检查重试
```mermaid
flowchart TD
A[执行 verify-ip] --> B[LLM/规则审核单次返回]
B --> C{SUCCESS 是否为 true}
C -- 是 --> D[清理重试计数,标记 verify-ip completed]
C -- 否 --> E{是否达到 VERIFY_MAX_ATTEMPTS}
E -- 否 --> F[播报 ACTION_PROGRESS 并保存 checkpoint]
F --> G[等待 VERIFY_INTERVAL_SEC]
G --> A
E -- 是 --> H[暂停在 verify-ip写入 review_context]
```
说明:
- `verify-ip` 用于应用启动后的健康检查,失败时默认每 `10` 秒重试一次,最多 `12` 次,约两分钟。
- 重试参数来自 `VERIFY_INTERVAL_SEC``VERIFY_MAX_ATTEMPTS`,支持通过 `config.txt`、chat `set``load params` 热更新。
- 未达到最大次数时不会把 `verify-ip` 写入 completed也不会进入 `download-log`;中断或失败后 `resume` 仍从 `verify-ip` 继续。
## 进度查询 action 语义
```mermaid

View File

@ -71,13 +71,15 @@ cd pam-deploy-agent-linux-x86_64
本次发布包对应的运行时行为也已同步到包内 `README.md`
- 每个 action 完成后都会自动执行一次 LLM/规则审核,只有审核通过才会把 action 记为 completed。
- action 审核输入不包含完整运行态 `state_summary`,只包含当前 action 的结构化结果和必要诊断日志,避免历史状态干扰大模型判断。
- `poll-download-progress``poll-upgrade-progress` 是单次进度查询 actionAgent workflow 会按配置重复调用,每次返回后交给 LLM/规则判断是否完成并播报进度。
- `verify-ip` 会按 `VERIFY_INTERVAL_SEC` / `VERIFY_MAX_ATTEMPTS` 做应用健康检查重试,默认每 10 秒一次、最多 12 次,仍未通过才暂停。
- `--analyze-actions` 只控制是否把详细审核结果写入 `events`
- action 失败或审核阻断后会保存 checkpoint 并暂停;修复外部环境后通过 `resume` 从当前 action 重试。
- 回滚不再属于主 workflow 自动分支;需要时使用 chat 内 `rollback [IP]` 或 CLI `rollback --checkpoint ...` 显式执行。
- chat 支持执行中 `Ctrl+C` 中断后保存 checkpoint再通过 `resume` 重试当前 action。
- chat 支持 `set KEY=VALUE``load params <路径>` 热更新当前运行任务参数。
- 进度查询间隔和最大次数可通过 `POLL_INTERVAL_SEC``DOWNLOAD_POLL_MAX_ATTEMPTS``UPGRADE_POLL_MAX_ATTEMPTS` 配置。
- 进度查询和健康检查重试参数可通过 `POLL_INTERVAL_SEC``DOWNLOAD_POLL_MAX_ATTEMPTS``UPGRADE_POLL_MAX_ATTEMPTS`、`VERIFY_INTERVAL_SEC``VERIFY_MAX_ATTEMPTS` 配置。
- 支持通过 `--llm-action-analysis-prompt-file` 或 chat 内 `llm config action_analysis_prompt_file=...` 自定义 action 审核提示词。
- chat 支持 `llm test [文本]` 测试当前 LLM client 是否正常加载。
- 默认运行日志写入 `logs/pam_deploy_agent.log`,可通过 `PAM_AGENT_LOG_FILE``PAM_AGENT_LOG_LEVEL` 调整。

View File

@ -36,8 +36,8 @@ pam-deploy-agent-linux-x86_64/
发布包默认会优先使用 `prompt_toolkit` 增强输入,支持更稳定的退格、历史记录和补全;如果增强输入初始化失败,会自动降级到普通 `input()`。输出仍会在可用时使用 `rich` 做更清晰的文本展示。
action 失败或审核阻断后会保存 checkpoint 并暂停;修复外部环境后输入 `resume` 会从当前 action 重试。回滚不再属于主 workflow 自动分支,需要时在 chat 内输入 `rollback [IP]` 显式执行。
chat 会在执行前归一化并展示实际写入脚本配置的参数;`script_only` / `hybrid_node_mcp` 会先检查 `ZIP_FILE_PATH` 是否存在,避免脚本运行后才用默认路径失败。执行过程中每个 action 都会输出开始、完成或失败状态;每个 action 完成后还会自动进入一次 LLM/规则审核,并播报审核开始和审核结果;只有审核通过才会把 action 记为 completed。
`poll-download-progress``poll-upgrade-progress` 每次只查询一次进度Agent workflow 会按 `POLL_INTERVAL_SEC``DOWNLOAD_POLL_MAX_ATTEMPTS``UPGRADE_POLL_MAX_ATTEMPTS` 重复调用,并在每次返回后交给 LLM/规则判断是否完成、向 chat 播报进度。
chat 会在执行前归一化并展示实际写入脚本配置的参数;`script_only` / `hybrid_node_mcp` 会先检查 `ZIP_FILE_PATH` 是否存在,避免脚本运行后才用默认路径失败。执行过程中每个 action 都会输出开始、完成或失败状态;每个 action 完成后还会自动进入一次 LLM/规则审核,并播报审核开始和审核结果;审核输入只包含当前 action 的结构化结果和必要诊断日志,不会把完整运行态 `state_summary` 交给大模型;只有审核通过才会把 action 记为 completed。
`poll-download-progress``poll-upgrade-progress` 每次只查询一次进度Agent workflow 会按 `POLL_INTERVAL_SEC``DOWNLOAD_POLL_MAX_ATTEMPTS``UPGRADE_POLL_MAX_ATTEMPTS` 重复调用,并在每次返回后交给 LLM/规则判断是否完成、向 chat 播报进度。`verify-ip` 健康检查失败时Agent workflow 会按 `VERIFY_INTERVAL_SEC` 重试,最多 `VERIFY_MAX_ATTEMPTS` 次;默认每 10 秒一次、最多 12 次,仍未通过才暂停。
## 交互式使用
@ -244,7 +244,9 @@ MCP token 获取方式与 HOME 一致,默认按 `client_credentials` POST 到
- 执行真实 action 前请确认配置文件中的 `HOME_BASE_URL``CLIENT_ID``CLIENT_SECRET``AIRPORT_CODE``APP_NAME``MODULE_NAME``VERSION_NUMBER``ZIP_FILE_PATH`
- `chat` 中输入 `你好``hello` 这类问候不会触发 LLM/结构化分析;需要分析部署需求时请直接描述部署任务,或显式使用 `analyze <需求>`
- 每个 action 完成后都会自动执行一次 LLM/规则审核;`--analyze-actions``llm action-analysis on` 只控制是否把详细审核结果写入 `events`
- action 审核输入不包含完整运行态 `state_summary`,只包含当前 action 的结构化结果和必要诊断日志。
- `poll-download-progress``poll-upgrade-progress` 是单次进度查询 action未完成时不会进入下一个 action最大查询次数和间隔可通过 `config.txt` 或 chat `set` 热更新。
- `verify-ip` 会按 `VERIFY_INTERVAL_SEC` / `VERIFY_MAX_ATTEMPTS` 做健康检查重试,默认每 10 秒一次、最多 12 次。
- `llm test [文本]` 可测试当前 LLM client 是否可用。
- 如果审核建议停止、审核本身失败,或用户在执行中按下 `Ctrl+C`,流程都会保存 checkpoint 并进入暂停状态;后续可使用 `resume` 重试当前 action。
- `set KEY=VALUE``load params <路径>` 会热更新当前运行任务的参数,并回写运行中的 `config.txt` 和 checkpoint。

View File

@ -33,6 +33,7 @@ REQUIRED_ACTION_VALUES = {
}
PROGRESS_ACTIONS = {"poll-download-progress", "poll-upgrade-progress"}
VERIFY_ACTION = "verify-ip"
class PamDeployAgent:
@ -557,6 +558,10 @@ class PamDeployAgent:
)
analysis = self._append_action_analysis(state, action, result, ip=ip)
if self._handle_verify_retry(state, ip, action, result, analysis, failed):
ip_state["status"] = "RUNNING"
return state
if failed:
fail_event = {
"type": "ACTION_FAIL",
@ -1022,6 +1027,94 @@ class PamDeployAgent:
)
return max(max_attempts, 1), max(interval_sec, 0.0)
def _handle_verify_retry(
self,
state: AgentState,
ip: str,
action: str,
result: ActionResult,
analysis: LlmActionAnalysis | None,
failed: bool,
) -> bool:
"""处理 verify-ip 的应用启动等待;未通过但未超时则保留当前 action 重试。"""
if action != VERIFY_ACTION:
return False
key = self._poll_attempt_key(action, ip=ip)
if not failed:
state.poll_attempts.pop(key, None)
return False
max_attempts, interval_sec = self._verify_limits(state)
attempt = state.poll_attempts.get(key, 0) + 1
state.poll_attempts[key] = attempt
message = self._verify_message(result, ip=ip, attempt=attempt, max_attempts=max_attempts)
progress_event = {
"type": "ACTION_PROGRESS",
"stage": action,
"backend": result.backend,
"ip": ip,
"message": message,
"attempt": attempt,
"max_attempts": max_attempts,
"values": dict(result.values),
}
state.events.append(progress_event)
self._emit_progress(progress_event)
if attempt >= max_attempts:
logger.warning(
"verify-ip 达到最大检查次数 run_id=%s ip=%s attempt=%s max=%s result=%s analysis=%s",
state.run_id,
ip,
attempt,
max_attempts,
_action_result_for_log(result),
json_for_log(asdict(analysis)) if analysis else "",
)
return False
self._save_checkpoint(state)
logger.info(
"verify-ip 未通过,等待后重试 run_id=%s ip=%s attempt=%s max=%s interval=%s message=%s",
state.run_id,
ip,
attempt,
max_attempts,
interval_sec,
message,
)
if interval_sec > 0:
time.sleep(interval_sec)
return True
def _verify_limits(self, state: AgentState) -> tuple[int, float]:
"""从运行参数读取 verify-ip 健康检查最大次数和间隔。"""
interval_sec = _safe_float(state.params.get("VERIFY_INTERVAL_SEC"), float(DEFAULT_PARAMS["VERIFY_INTERVAL_SEC"]))
max_attempts = _safe_int(
state.params.get("VERIFY_MAX_ATTEMPTS"),
int(DEFAULT_PARAMS["VERIFY_MAX_ATTEMPTS"]),
)
return max(max_attempts, 1), max(interval_sec, 0.0)
def _verify_message(
self,
result: ActionResult,
*,
ip: str,
attempt: int,
max_attempts: int,
) -> str:
"""格式化 verify-ip 重试播报。"""
parts = [f"IP={ip}", f"{attempt}/{max_attempts} 次健康检查"]
for key in ("SUCCESS", "MESSAGE", "STATUS", "CODE"):
value = result.values.get(key)
if value not in (None, ""):
parts.append(f"{key}={value}")
if result.error_summary:
parts.append(f"error_summary={result.error_summary}")
return "".join(parts)
def _progress_complete(
self,
action: str,
@ -1145,7 +1238,6 @@ class PamDeployAgent:
analysis = self.llm_client.analyze_action_result(
action=action,
result=result,
state_summary=self._state_summary_for_llm(state, ip=ip),
)
except Exception as exc: # pragma: no cover - 审核失败时也要显式暂停,避免黑盒继续执行
logger.exception("LLM action 审核失败 run_id=%s action=%s ip=%s", state.run_id, action, ip or "")
@ -1202,24 +1294,6 @@ class PamDeployAgent:
)
return analysis
def _state_summary_for_llm(self, state: AgentState, *, ip: str | None = None) -> dict[str, Any]:
"""生成给 LLM action 分析使用的脱敏状态摘要。"""
return {
"run_id": state.run_id,
"execution_strategy": state.execution_strategy,
"completed_global_steps": state.completed_global_steps,
"online_ip_count": len(state.online_ips),
"target_ips": state.target_ips,
"current_ip": ip or "",
"current_ip_state": state.ip_states.get(ip, {}) if ip else {},
"pending_confirmation": state.pending_confirmation,
"paused": state.paused,
"pause_reason": state.pause_reason,
"last_success_step": state.last_success_step,
"last_failed_step": state.last_failed_step,
"poll_attempts": state.poll_attempts,
}
def _review_context(
self,
*,

View File

@ -20,6 +20,8 @@ CONFIG_KEYS = (
"POLL_INTERVAL_SEC",
"DOWNLOAD_POLL_MAX_ATTEMPTS",
"UPGRADE_POLL_MAX_ATTEMPTS",
"VERIFY_INTERVAL_SEC",
"VERIFY_MAX_ATTEMPTS",
)

View File

@ -67,6 +67,8 @@ DEFAULT_PARAMS = {
"POLL_INTERVAL_SEC": 2,
"DOWNLOAD_POLL_MAX_ATTEMPTS": 60,
"UPGRADE_POLL_MAX_ATTEMPTS": 600,
"VERIFY_INTERVAL_SEC": 10,
"VERIFY_MAX_ATTEMPTS": 12,
}
# 日志、报告和 LLM 输入中需要脱敏的字段。

View File

@ -40,7 +40,6 @@ class LlmClient(Protocol):
*,
action: str,
result: ActionResult,
state_summary: dict[str, Any],
) -> LlmActionAnalysis:
"""分析 action 执行结果,并给出是否允许继续执行的建议。"""
...

View File

@ -150,7 +150,6 @@ class OpenAICompatibleLlmClient:
*,
action: str,
result: ActionResult,
state_summary: dict[str, Any],
) -> LlmActionAnalysis:
"""调用 LLM 分析 action 结果,返回结构化诊断建议。"""
payload = self._complete_json(
@ -159,7 +158,6 @@ class OpenAICompatibleLlmClient:
{
"action": action,
"result": _action_review_result_payload(action, result),
"state_summary": _redact_sensitive(state_summary),
},
)
return LlmActionAnalysis(

View File

@ -41,7 +41,9 @@ PARAM_PROMPT = """从用户输入中抽取 PAM 部署参数和控制信息。
"LOG_NAME": "...",
"POLL_INTERVAL_SEC": "...",
"DOWNLOAD_POLL_MAX_ATTEMPTS": "...",
"UPGRADE_POLL_MAX_ATTEMPTS": "..."
"UPGRADE_POLL_MAX_ATTEMPTS": "...",
"VERIFY_INTERVAL_SEC": "...",
"VERIFY_MAX_ATTEMPTS": "..."
},
"extracted_control": {
"user_specified_ips": ["..."]
@ -91,7 +93,9 @@ ACTION_ANALYSIS_PROMPT = """分析一次 PAM action 执行结果。
- 进度 action 未完成但正常时`has_anomaly=false``should_continue=true``progress_complete=false`建议继续查询进度
- 进度 action 完成条件优先看 `STEP=DONE``STATUS=completed/done/success``SUCCESS=true``FINISH=true` `MSG=success` `RATE_OF_PROGRESS=100` `CODE` 为空或 0
- 进度 action 出现 `CODE` 0 `STEP/MSG/STATUS/MESSAGE` fail/error应标记异常并 `should_continue=false`
- 主要依据结构化字段 `ok``exit_code``values``error_summary` 判断只有输入里存在 `diagnostic_log` 才把它当作异常诊断上下文
- 主要依据结构化字段 `ok``exit_code``values``error_summary` 判断不会提供完整运行态摘要避免被历史状态误导
- `verify-ip SUCCESS=false` runtime 按配置重复检查单次审核仍应说明当前健康检查未通过
- 只有输入里存在 `diagnostic_log` 才把它当作异常诊断上下文
- 脚本正常过程日志不会作为错误依据不能因为日志来自 stderr 就判定异常
- 不要输出密钥tokenAuthorization 或完整日志原文
"""

View File

@ -166,11 +166,10 @@ class RuleBasedLlmClient:
*,
action: str,
result: ActionResult,
state_summary: dict[str, Any],
) -> LlmActionAnalysis:
"""用本地规则分析 action 结果,作为真实 LLM 不可用时的兜底。"""
logger.info(
"规则 LLM action 审核开始 action=%s result=%s state_summary=%s",
"规则 LLM action 审核开始 action=%s result=%s",
action,
json_for_log(
{
@ -183,7 +182,6 @@ class RuleBasedLlmClient:
},
max_text_len=1000,
),
json_for_log(state_summary),
)
notes: list[str] = []
has_anomaly = not result.ok

View File

@ -20,6 +20,8 @@
- 进度 action 未完成但正常时,`has_anomaly=false`、`should_continue=true`、`progress_complete=false`,建议继续查询进度。
- 进度 action 完成条件优先看 `STEP=DONE`、`STATUS=completed/done/success`、`SUCCESS=true`、`FINISH=true`,或 `MSG=success` 且 `RATE_OF_PROGRESS=100` 且 `CODE` 为空或 0。
- 进度 action 出现 `CODE` 非 0或 `STEP/MSG/STATUS/MESSAGE` 含 fail/error应标记异常并 `should_continue=false`。
- 主要依据结构化字段 `ok`、`exit_code`、`values`、`error_summary` 判断;只有输入里存在 `diagnostic_log` 时,才把它当作异常诊断上下文。
- 主要依据结构化字段 `ok`、`exit_code`、`values`、`error_summary` 判断;不会提供完整运行态摘要,避免被历史状态误导。
- `verify-ip SUCCESS=false` 由 runtime 按配置重复检查;单次审核仍应说明当前健康检查未通过。
- 只有输入里存在 `diagnostic_log` 时,才把它当作异常诊断上下文。
- 脚本正常过程日志不会作为错误依据,不能因为日志来自 stderr 就判定异常。
- 不要输出密钥、token、Authorization 或完整日志原文。

View File

@ -18,11 +18,13 @@ PARAMS = {
"MODULE_NAME": "Node",
"VERSION_NUMBER": "2.0.5",
"ZIP_FILE_PATH": "C:/pkg.zip",
"VERIFY_INTERVAL_SEC": 0,
"VERIFY_MAX_ATTEMPTS": 2,
}
class BlockingReviewLlmClient:
def analyze_action_result(self, *, action, result, state_summary):
def analyze_action_result(self, *, action, result):
return LlmActionAnalysis(
action=action,
has_anomaly=True,
@ -40,7 +42,7 @@ class BlockingOnceReviewLlmClient:
self.blocked_action = blocked_action
self.blocked = False
def analyze_action_result(self, *, action, result, state_summary):
def analyze_action_result(self, *, action, result):
if action == self.blocked_action and not self.blocked:
self.blocked = True
return LlmActionAnalysis(
@ -56,7 +58,7 @@ class BlockingOnceReviewLlmClient:
class BrokenReviewLlmClient:
def analyze_action_result(self, *, action, result, state_summary):
def analyze_action_result(self, *, action, result):
raise RuntimeError("review transport failed")
@ -93,6 +95,26 @@ class ProgressivePollRunner(FakeActionRunner):
return super()._fixture_for(action, kwargs)
class FlakyVerifyRunner(FakeActionRunner):
"""模拟应用启动后第二次健康检查通过。"""
def __init__(self) -> None:
super().__init__()
self.verify_calls = 0
def _fixture_for(self, action, kwargs):
if action == "verify-ip" and kwargs.get("ip") == "192.168.1.10":
self.verify_calls += 1
if self.verify_calls == 1:
return {
"ACTION": action,
"IP": "192.168.1.10",
"SUCCESS": "false",
"MESSAGE": "application is starting",
}
return super()._fixture_for(action, kwargs)
def test_run_deploy_flow_success(tmp_path: Path):
agent = PamDeployAgent(fake_runner=FakeActionRunner())
state = agent.create_state(
@ -161,6 +183,30 @@ def test_progress_timeout_pauses_on_current_action(tmp_path: Path):
assert state.poll_attempts["global:poll-download-progress"] == 2
def test_verify_ip_retries_until_success_before_marking_failed(tmp_path: Path):
fake = FlakyVerifyRunner()
agent = PamDeployAgent(fake_runner=fake)
state = agent.create_state(
params=PARAMS,
execution_strategy="fake",
config_path=str(tmp_path / "config.txt"),
checkpoint_path=str(tmp_path / "checkpoint.json"),
)
agent.run_deploy_flow(state)
assert fake.verify_calls == 2
assert state.paused is False
assert state.poll_attempts == {}
assert state.ip_states["192.168.1.10"]["status"] == "SUCCESS"
assert any(
event["type"] == "ACTION_PROGRESS"
and event["stage"] == "verify-ip"
and event["ip"] == "192.168.1.10"
for event in state.events
)
def test_create_state_writes_absolute_script_config_path_and_normalized_zip(tmp_path: Path):
package_path = tmp_path / "pkg.zip"
params = {**PARAMS, "ZIP_FILE_PATH": str(package_path)}
@ -216,6 +262,8 @@ def test_run_deploy_flow_stops_on_verify_failure(tmp_path: Path):
agent.run_deploy_flow(state)
verify_calls = [call for call in fake.calls if call[0] == "verify-ip" and call[1].get("ip") == "192.168.1.10"]
assert len(verify_calls) == 2
assert state.pending_confirmation == ""
assert state.paused is True
assert state.pause_reason == "action_failed"

View File

@ -19,11 +19,13 @@ PARAMS = {
"MODULE_NAME": "Node",
"VERSION_NUMBER": "2.0.5",
"ZIP_FILE_PATH": "C:/pkg.zip",
"VERIFY_INTERVAL_SEC": 0,
"VERIFY_MAX_ATTEMPTS": 2,
}
class BlockingReviewLlmClient:
def analyze_action_result(self, *, action, result, state_summary):
def analyze_action_result(self, *, action, result):
return LlmActionAnalysis(
action=action,
has_anomaly=True,
@ -56,7 +58,7 @@ class FakeTestableLlmClient:
def generate_plan(self, *, params, intent, strategy):
raise AssertionError("llm test should only call understand_request")
def analyze_action_result(self, *, action, result, state_summary):
def analyze_action_result(self, *, action, result):
return LlmActionAnalysis(action=action)
@ -232,7 +234,7 @@ def test_chat_resume_retries_failed_ip_without_rollback(tmp_path: Path):
checkpoint_path=str(tmp_path / "checkpoint.json"),
)
output = run_session(session, ["run", "yes", "yes", "yes", "resume", "exit"])
output = run_session(session, ["run", "yes", "yes", "yes", "exit"])
assert session.state is not None
assert session.state.pending_confirmation == ""
@ -241,7 +243,7 @@ def test_chat_resume_retries_failed_ip_without_rollback(tmp_path: Path):
assert session.state.ip_states["192.168.1.10"]["status"] == "SUCCESS"
assert session.state.ip_states["192.168.1.11"]["status"] == "SUCCESS"
assert not any(call[0] == "rollback-ip" for call in fake.calls)
assert any("如需回滚,输入 rollback 192.168.1.10" in item for item in output)
assert any("进度更新: verify-ip" in item for item in output)
def test_chat_explicit_rollback_command_rolls_back_failed_ip(tmp_path: Path):

View File

@ -14,6 +14,8 @@ PARAMS = {
"MODULE_NAME": "Node",
"VERSION_NUMBER": "2.0.5",
"ZIP_FILE_PATH": "C:/pkg.zip",
"VERIFY_INTERVAL_SEC": 0,
"VERIFY_MAX_ATTEMPTS": 2,
}

View File

@ -221,7 +221,6 @@ def test_openai_compatible_client_analyzes_action_result_with_redaction():
values={"CLIENT_SECRET": "real-secret", "SUCCESS": "false"},
stderr="x" * 1200,
),
state_summary={"params": {"CLIENT_SECRET": "real-secret"}},
)
serialized_prompt = str(calls[0])
@ -229,6 +228,7 @@ def test_openai_compatible_client_analyzes_action_result_with_redaction():
assert analysis.has_anomaly is True
assert analysis.severity == "high"
assert "real-secret" not in serialized_prompt
assert "state_summary" not in input_payload
assert input_payload["result"]["diagnostic_log"].startswith("[已截断]...")
@ -268,7 +268,6 @@ def test_openai_compatible_client_omits_success_script_logs_from_action_review()
stdout="ACTION=get-online-ips\nCOUNT=1\nIP=10.4.1.1\n",
stderr="[INFO] [FLOW][START] get_token\n[INFO] [FLOW][DONE] get_online_ips\n",
),
state_summary={},
)
input_payload = _llm_input_payload(calls[0])