From 4250a7b2211923fd4ef2cc958542c69a78ab8ced Mon Sep 17 00:00:00 2001 From: dark Date: Thu, 4 Jun 2026 16:57:16 +0800 Subject: [PATCH] =?UTF-8?q?LLM=20action=20=E7=BB=93=E6=9E=9C=E5=88=86?= =?UTF-8?q?=E6=9E=90=E4=B8=8D=E5=86=8D=E4=BC=A0=20state=5Fsummary=20?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BA=86=20agent.py=20=E5=92=8C=20LLM=20clie?= =?UTF-8?q?nt=20=E5=8D=8F=E8=AE=AE/=E5=AE=9E=E7=8E=B0=E3=80=82=20=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E5=8F=AA=E4=BC=A0=E5=BD=93=E5=89=8D=20action=20?= =?UTF-8?q?=E7=9A=84=E7=BB=93=E6=9E=84=E5=8C=96=E7=BB=93=E6=9E=9C=E5=92=8C?= =?UTF-8?q?=E5=BF=85=E8=A6=81=E8=AF=8A=E6=96=AD=E6=97=A5=E5=BF=97=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=8E=86=E5=8F=B2=E8=BF=90=E8=A1=8C=E6=80=81?= =?UTF-8?q?=E5=BD=B1=E5=93=8D=E5=88=A4=E6=96=AD=E3=80=82=20=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E5=92=8C=E6=96=87=E6=A1=A3=E4=B9=9F=E5=B7=B2?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=AF=B4=E6=98=8E=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-ip 增加健康检查重试 默认 VERIFY_INTERVAL_SEC=10、VERIFY_MAX_ATTEMPTS=12,约 2 分钟。 verify-ip 未通过但未达到最大次数时,会播报进度、保存 checkpoint,并继续从当前 verify-ip 重试,不会进入 download-log。 参数已加入 config.txt.example、脚本配置读取、README、打包 README、Skill 文档和流程图。 --- README.md | 8 +- doc_scripts/PAM_AUTO_DEPLY_SKILL.md | 18 ++-- doc_scripts/config.txt.example | 2 + doc_scripts/deploy.ps1 | 4 + doc_scripts/deploy.sh | 6 +- docs/current_logic_flow.md | 30 +++++- packaging/README_linux_package.md | 4 +- packaging/README_packaged_agent.md | 6 +- pam_deploy_graph/agent.py | 112 ++++++++++++++++++---- pam_deploy_graph/config_writer.py | 2 + pam_deploy_graph/constants.py | 2 + pam_deploy_graph/llm/base.py | 1 - pam_deploy_graph/llm/openai_compatible.py | 2 - pam_deploy_graph/llm/prompts.py | 8 +- pam_deploy_graph/llm/rule_based.py | 4 +- prompts/action_review.txt | 4 +- tests/test_agent_flow.py | 54 ++++++++++- tests/test_interactive_cli.py | 10 +- tests/test_langgraph_runtime.py | 2 + tests/test_llm_structured.py | 3 +- 20 files changed, 229 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index f324c23..bf30465 100644 --- a/README.md +++ b/README.md @@ -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`。 ## 日志 diff --git a/doc_scripts/PAM_AUTO_DEPLY_SKILL.md b/doc_scripts/PAM_AUTO_DEPLY_SKILL.md index 91e555d..fdcd28b 100644 --- a/doc_scripts/PAM_AUTO_DEPLY_SKILL.md +++ b/doc_scripts/PAM_AUTO_DEPLY_SKILL.md @@ -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` | 未显式要求回滚时不自动回滚 | diff --git a/doc_scripts/config.txt.example b/doc_scripts/config.txt.example index 3ebbeb3..7841327 100644 --- a/doc_scripts/config.txt.example +++ b/doc_scripts/config.txt.example @@ -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 diff --git a/doc_scripts/deploy.ps1 b/doc_scripts/deploy.ps1 index 56b9335..07aba3e 100644 --- a/doc_scripts/deploy.ps1 +++ b/doc_scripts/deploy.ps1 @@ -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) { diff --git a/doc_scripts/deploy.sh b/doc_scripts/deploy.sh index 9110874..287474b 100644 --- a/doc_scripts/deploy.sh +++ b/doc_scripts/deploy.sh @@ -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 diff --git a/docs/current_logic_flow.md b/docs/current_logic_flow.md index b2c1c40..45b2b8d 100644 --- a/docs/current_logic_flow.md +++ b/docs/current_logic_flow.md @@ -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 diff --git a/packaging/README_linux_package.md b/packaging/README_linux_package.md index 67b6edc..204ce89 100644 --- a/packaging/README_linux_package.md +++ b/packaging/README_linux_package.md @@ -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` 是单次进度查询 action;Agent 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` 调整。 diff --git a/packaging/README_packaged_agent.md b/packaging/README_packaged_agent.md index d246f28..4653eba 100644 --- a/packaging/README_packaged_agent.md +++ b/packaging/README_packaged_agent.md @@ -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。 diff --git a/pam_deploy_graph/agent.py b/pam_deploy_graph/agent.py index 927f90b..3c6e48d 100644 --- a/pam_deploy_graph/agent.py +++ b/pam_deploy_graph/agent.py @@ -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, *, diff --git a/pam_deploy_graph/config_writer.py b/pam_deploy_graph/config_writer.py index 58c59fc..05e7b0f 100644 --- a/pam_deploy_graph/config_writer.py +++ b/pam_deploy_graph/config_writer.py @@ -20,6 +20,8 @@ CONFIG_KEYS = ( "POLL_INTERVAL_SEC", "DOWNLOAD_POLL_MAX_ATTEMPTS", "UPGRADE_POLL_MAX_ATTEMPTS", + "VERIFY_INTERVAL_SEC", + "VERIFY_MAX_ATTEMPTS", ) diff --git a/pam_deploy_graph/constants.py b/pam_deploy_graph/constants.py index 1868de7..2d0b192 100644 --- a/pam_deploy_graph/constants.py +++ b/pam_deploy_graph/constants.py @@ -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 输入中需要脱敏的字段。 diff --git a/pam_deploy_graph/llm/base.py b/pam_deploy_graph/llm/base.py index eeefe0f..5aa9f8c 100644 --- a/pam_deploy_graph/llm/base.py +++ b/pam_deploy_graph/llm/base.py @@ -40,7 +40,6 @@ class LlmClient(Protocol): *, action: str, result: ActionResult, - state_summary: dict[str, Any], ) -> LlmActionAnalysis: """分析 action 执行结果,并给出是否允许继续执行的建议。""" ... diff --git a/pam_deploy_graph/llm/openai_compatible.py b/pam_deploy_graph/llm/openai_compatible.py index d778127..d570260 100644 --- a/pam_deploy_graph/llm/openai_compatible.py +++ b/pam_deploy_graph/llm/openai_compatible.py @@ -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( diff --git a/pam_deploy_graph/llm/prompts.py b/pam_deploy_graph/llm/prompts.py index 5a9280f..4408d8e 100644 --- a/pam_deploy_graph/llm/prompts.py +++ b/pam_deploy_graph/llm/prompts.py @@ -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 就判定异常。 - 不要输出密钥、token、Authorization 或完整日志原文。 """ diff --git a/pam_deploy_graph/llm/rule_based.py b/pam_deploy_graph/llm/rule_based.py index 5286650..f6cffb3 100644 --- a/pam_deploy_graph/llm/rule_based.py +++ b/pam_deploy_graph/llm/rule_based.py @@ -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 diff --git a/prompts/action_review.txt b/prompts/action_review.txt index c5dd4fa..f300a56 100644 --- a/prompts/action_review.txt +++ b/prompts/action_review.txt @@ -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 或完整日志原文。 diff --git a/tests/test_agent_flow.py b/tests/test_agent_flow.py index 8ad73d1..e4189e3 100644 --- a/tests/test_agent_flow.py +++ b/tests/test_agent_flow.py @@ -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" diff --git a/tests/test_interactive_cli.py b/tests/test_interactive_cli.py index a486983..332ac9b 100644 --- a/tests/test_interactive_cli.py +++ b/tests/test_interactive_cli.py @@ -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): diff --git a/tests/test_langgraph_runtime.py b/tests/test_langgraph_runtime.py index 499fc6d..fe72745 100644 --- a/tests/test_langgraph_runtime.py +++ b/tests/test_langgraph_runtime.py @@ -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, } diff --git a/tests/test_llm_structured.py b/tests/test_llm_structured.py index ca5e975..5e7cb1d 100644 --- a/tests/test_llm_structured.py +++ b/tests/test_llm_structured.py @@ -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])