diff --git a/scripts/a2a/smoke/test_a2a_vpc.py b/scripts/a2a/smoke/test_a2a_vpc.py index be3107f7e..15c8f78b7 100644 --- a/scripts/a2a/smoke/test_a2a_vpc.py +++ b/scripts/a2a/smoke/test_a2a_vpc.py @@ -17,6 +17,7 @@ import argparse import json import os +import re import socket import subprocess import sys @@ -342,27 +343,41 @@ def _normal_turn_completed(task: dict, *, timeout: float) -> bool: def _normal_turn_output(task: dict, cli_text: str, *, permission_submitted: bool) -> str: # A reply consumer may retain an old permission Task even after its owner - # finished. After the matching completion proof, accept only a whole ROS + # finished. After the matching completion proof, accept only a complete ROS # template from history or the just-finished CLI call, never a receipt. if not permission_submitted: # No reply/receipt call replaced the original sync output. Once that # task has released naturally, keep its own CLI reply for acceptance. return cli_text or _completed_task_text(task) + metadata = task.get('metadata') + iac = metadata.get('iac_code') if isinstance(metadata, dict) else None + input_resolved = not isinstance(iac, dict) or ( + iac.get('input') is None and not iac.get('pendingPermissions') + ) for text in (_completed_task_text(task), cli_text): wire = text.strip() lines = wire.splitlines() if len(lines) >= 3 and lines[0] in {'```', '```json'} and lines[-1] == '```': wire = '\n'.join(lines[1:-1]) - try: - value = json.loads(wire) - except ValueError: - continue - resources = value.get('Resources') if isinstance(value, dict) else None - if isinstance(resources, dict) and any( - isinstance(resource, dict) and resource.get('Type') == 'ALIYUN::ECS::VPC' - for resource in resources.values() - ): - return text + candidates = [wire] + if input_resolved: + # A final reply may introduce its fenced template with prose. Do + # not extract snippets from a still-pending permission prompt. + candidates.extend(re.findall( + r'^[ \t]*```(?:json)?[ \t]*\r?\n(.*?)\r?\n[ \t]*```[ \t]*$', + text, flags=re.MULTILINE | re.DOTALL, + )) + for candidate in candidates: + try: + value = json.loads(candidate) + except ValueError: + continue + resources = value.get('Resources') if isinstance(value, dict) else None + if isinstance(resources, dict) and any( + isinstance(resource, dict) and resource.get('Type') == 'ALIYUN::ECS::VPC' + for resource in resources.values() + ): + return text return '' diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 08afe1e59..aed31da2a 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -13,7 +13,7 @@ uv run --no-sync python scripts/ci/run_e2e.py --suite full --jobs 3 uv run --no-sync python scripts/ci/run_e2e.py --list --suite all ``` -`fast` 包含 7 个 A2A、REPL、Web API 确定性契约用例;`full` 共 64 个,另含 A2A 执行控制与权限等待/恢复矩阵。`e3a-recovery` 保留运行中快照保存后的崩溃故障点,并验证用户手动继续后恢复同一任务;另设 `e3a-handoff-recovery` 验证已完成回合后的安全交接点重启,两者独立验收。Web API 用例明确使用 `--skip-browser`,不启动浏览器。只跑一个用例可用 `--case a2a-recovery-contract` 或 `--case a2a-handoff-recovery-contract`。`--jobs` 限定为 1–16,确定性套件默认 3,真实套件默认 12。每个用例用独立子进程、配置目录和日志目录;确定性用例不会继承常见 LLM 与阿里云凭证环境变量。 +`fast` 包含 7 个 A2A、REPL、Web API 确定性契约用例;`full` 共 66 个,另含 A2A 执行控制与权限等待/恢复矩阵。`e3a-recovery` 保留运行中快照保存后的崩溃故障点,并验证用户手动继续后恢复同一任务;另设 `e3a-handoff-recovery` 验证已完成回合后的安全交接点重启,两者独立验收。Web API 用例明确使用 `--skip-browser`,不启动浏览器。只跑一个用例可用 `--case a2a-recovery-contract` 或 `--case a2a-handoff-recovery-contract`。`--jobs` 限定为 1–16,确定性套件默认 3,真实套件默认 12。每个用例用独立子进程、配置目录和日志目录;确定性用例不会继承常见 LLM 与阿里云凭证环境变量。 权限批次新增四条 HTTP E2E,复用 `tests/a2a/test_permission_prompt_e2e.py`,仅模型与工具使用确定性替身。`permission-prompt-supersede-{same-process,after-restart}` 验证权限待确认时的新 prompt 取消整个未执行批次、补齐 tool_result,并阻止旧权限回复重新执行;`permission-confirmation-followup-{same-process,after-restart}` 验证连续正常确认权限后,工具只执行一次,下一条普通 prompt 正常处理,重复权限回复不会重跑工具。四条均纳入 `full`,也可单独执行: @@ -35,7 +35,7 @@ uv run --no-sync python scripts/ci/run_e2e.py --suite live --jobs 4 \ --allow-cloud-write ``` -默认三文件模式下,凭证目录须含 `.credentials.yml`、`.cloud-credentials.yml`、`settings.yml`。建议使用专用测试账号、受限权限和资源配额。`live` 共 121 个:42 个 selling flow A2A/REPL 场景、20 个 A2A 只读资源选择(含 12 个重启恢复矩阵)、6 个 AGUI HTTP/SSE 资源选择、16 个 REPL 旧场景、33 个 A2A 恢复场景、3 个 VPC 模板 smoke、1 个只读云 API canary。其中包含真实 ROS 资源创建用例。需要缩小范围时可选 `live-core`、`live-recovery`、`live-multimodal`、`live-readonly`、`live-legacy`、`live-safety`、`live-repl`、`live-smoke` 或 `live-agui`,也可用 `--case` 指定单个用例。AGUI 的 6 条覆盖普通聊天与 Pipeline 的选择、取消和直接输入,通过 HTTP/SSE 运行,不需要浏览器;使用文本模型池,查询已有 VPC,不创建资源,清理状态为“无需清理”。可单独运行 `--suite live-agui`,或指定 `--case agui-selector-normal-selected`。三个 VPC smoke 只接收 LLM 与 settings 配置,使用隔离的 HOME,不接收云凭证;它们验证生成模板,不创建 VPC。并行 selling 场景各用独立的 `10.250.0.0/16` 子网池,避免独立进程重复预留同一 VSwitch CIDR;原 runner 单独运行仍沿用原池。旧 A2A、REPL 和 selling 场景不强制测试 StackName;清理读取隔离会话中真实 CreateStack 接受回执,按实际 ID、名称和地域校验后删除。仅查询、等待或模型提及的资源不能授权删除;硬超时后由独立清理进程再次尝试。selling、REPL 旧用例和 A2A 恢复用例的硬超时为 45 分钟,之后最多留 15 分钟清理,再强制结束进程组。硬超时不代表清理成功,需检查报告和测试账号残留资源。 +默认三文件模式下,凭证目录须含 `.credentials.yml`、`.cloud-credentials.yml`、`settings.yml`。建议使用专用测试账号、受限权限和资源配额。`live` 共 130 个:42 个 selling flow A2A/REPL 场景、20 个 A2A 只读资源选择(含 12 个重启恢复矩阵)、6 个 AGUI HTTP/SSE 资源选择、16 个 REPL 旧场景、33 个 A2A 恢复场景、3 个 VPC 模板 smoke、1 个只读云 API canary、8 个 ROS 助手引导真实模型场景、1 个活动商品追问 A2A 场景。其中包含真实 ROS 资源创建用例。需要缩小范围时可选 `live-core`、`live-recovery`、`live-multimodal`、`live-readonly`、`live-legacy`、`live-safety`、`live-repl`、`live-smoke` 或 `live-agui`,也可用 `--case` 指定单个用例。AGUI 的 6 条覆盖普通聊天与 Pipeline 的选择、取消和直接输入,通过 HTTP/SSE 运行,不需要浏览器;使用文本模型池,查询已有 VPC,不创建资源,清理状态为“无需清理”。可单独运行 `--suite live-agui`,或指定 `--case agui-selector-normal-selected`。三个 VPC smoke 只接收 LLM 与 settings 配置,使用隔离的 HOME,不接收云凭证;它们验证生成模板,不创建 VPC。并行 selling 场景各用独立的 `10.250.0.0/16` 子网池,避免独立进程重复预留同一 VSwitch CIDR;原 runner 单独运行仍沿用原池。旧 A2A、REPL 和 selling 场景不强制测试 StackName;清理读取隔离会话中真实 CreateStack 接受回执,按实际 ID、名称和地域校验后删除。仅查询、等待或模型提及的资源不能授权删除;硬超时后由独立清理进程再次尝试。selling、REPL 旧用例和 A2A 恢复用例的硬超时为 45 分钟,之后最多留 15 分钟清理,再强制结束进程组。硬超时不代表清理成功,需检查报告和测试账号残留资源。 CI 可改用 `--cloud-credential-helper /path/to/helper.py`,此时源目录只需 LLM 凭证和 `settings.yml`。Helper 接口为 `cloud --output <目标 .cloud-credentials.yml 路径>`;它在每个真实云用例开始前调用,长用例每 10 分钟调用一次,异常清理前也会再次调用。若 helper 需要独立 Python 环境,传 `--cloud-credential-python /path/to/python`。Helper 必须原子地写入私有权限文件,且不得在标准输出或错误输出打印凭证。模板 smoke 不调用 helper。本地原有的三文件运行方式继续支持。 @@ -55,6 +55,31 @@ CI 可改用 `--cloud-credential-helper /path/to/helper.py`,此时源目录只 `live-readonly` 新增 `selector-restart-{normal,pipeline}-{sigterm,sigkill,released}-{selected,canceled}` 共 12 条,复用真实资源选择器重启脚本,查询已有 VPC,不创建云资源;隔离配置、模型分配、凭证刷新与安全报告沿用 selector runner。每条核对恢复后首个 SSE 帧的 execution 归属、消费回执以及下一轮普通 prompt。 +`ssf-candidate-batch-{same-process,cold-recovery}` 是 P18 的确定性回归/确认用例,纳入 `full`。使用生产 `selling_solution_first` 流程、AgentLoop、展示工具、会话文件和恢复逻辑,仅模型输出使用脚本替身:展示方案及详情 → 相同方案幂等重复展示 → 等待选择 → 用户选择方案。验证选择后直接进入模板生成阶段,保留原批次和所选方案,不再调用模型或要求重选;冷恢复用例重新构造 Runner/Storage 并从磁盘恢复,不模拟进程重启。用例在下一步骤启动时结束,不调用真实模型或云 API。 + +```bash +uv run --no-sync python scripts/ci/run_e2e.py \ + --case ssf-candidate-batch-same-process \ + --case ssf-candidate-batch-cold-recovery --jobs 1 --retries 0 +``` + +`ros-awareness-*` 纳入 `live-readonly`,验证生产 Step 1 引擎和工具在真实模型下的分流:优惠、续费、计费、GPU 库存价格、流量抵扣、默认地域,以及开关关闭、部署成本咨询两个对照场景。计费保留自然问法 `ros-awareness-billing` 和明确不部署的问法 `ros-awareness-billing-explicit`,两者均须正确分流。验收用户可见链接、地域、持久化结束原因及澄清边界;不替换模型输出,不覆盖 HTTP/UI,不调用云 API。只复制 LLM 凭证和 settings,不接收云凭证或调用云凭证 helper。可定向运行: + +```bash +uv run --no-sync python scripts/ci/run_e2e.py \ + --case ros-awareness-renewal --case ros-awareness-default-region \ + --case ros-awareness-disabled --case ros-awareness-deployment-pricing \ + --credential-source-dir /path/to/test-config --jobs 2 --retries 0 +``` + +`promotional-offer-followup-a2a` 通过生产 HTTP/SSE A2A Server 验证真实模型:先为“最低 2G、推荐 4G”的旅游 Agent 规划服务器,再保留会话追问活动商品,最后明确咨询活动购买资格。检查内存下限、部署上下文中的同地域助手引导,以及独立活动咨询的可见链接和持久化结束原因。测试不替换模型或工具结果、不选择部署方案,仅复制 LLM 凭证与 settings,不接收云凭证、不查询实际活动资格;首轮历史附图中的规格以文本复现。四模型覆盖需分别指定每个 `--text-model` 运行,不把单次调度当成四模型全覆盖。 + +```bash +uv run --no-sync python scripts/ci/run_e2e.py --case promotional-offer-followup-a2a \ + --text-model deepseek-v4.1-flash --credential-source-dir /path/to/test-config \ + --jobs 1 --retries 0 +``` + ## 真实用例的模型池与思考强度 真实套件默认采用滚动调度:文本用例使用 `deepseek-v4-flash-0731`、`glm-5.2-fast-preview`、`glm-5.3-prime`、`deepseek-v4.1-flash`,每模型最多两个用例;多模态用例使用 `qwen3.8-max`、`qwen3.8-max-0902`、`qwen3.8-flash`、`qwen3.8-omni-flash`,每模型最多一个用例。两类模型池不能互相借用,总共最多 12 个用例。仅有文本用例待运行时最多八个;仅有多模态用例时最多四个。 @@ -81,7 +106,29 @@ uv run --no-sync python scripts/ci/run_e2e.py --case ssf-a2a-happy-multi-plan \ ## CI 接入 -CI 调用同一个入口,并用 `--run-dir` 指定报告目录。`fast` 适合快速检查,`full` 适合完整确定性验收。真实场景必须提供测试专用配置目录和 `--allow-cloud-write`。配置文件内容应通过 CI 的 Secret 注入,且不要进入代码、作业输出或上传附件。 +`coupon-budget-a2a` 使用真实模型、HTTP/SSE A2A 和只读云 API,验证默认月费、明确小时与时长后的估价,以及当前部署中的优惠券查询不被分流。它也验证优惠券指南中的两条 API、字段与分页,不购买资源或实际使用优惠券: + +```bash +uv run python scripts/ci/run_e2e.py --case coupon-budget-a2a --suite live \ + --credential-source-dir /path/to/test-config --text-model glm-5.2-fast-preview + +# 仅验证指南 API,使用当前配置的云凭证,不调用模型 +uv run python scripts/ci/run_coupon_budget_a2a.py --api-only --allow-real-cloud \ + --run-dir /tmp/coupon-api-check +``` + +API 验证需要账号内实际存在优惠券及可查询的商品记录;空列表不能作为券字段和商品查询已验证的证明。API/模型原始结果留在私有运行目录,不上传为公开报告。该用例验证只读识别与规划,不证明特定订单或账单一定能抵扣。 + +`coupon-budget-mock-a2a` 补充没有真实可用券时的判断覆盖:A2A Server、模型和方案规划均使用真实实现,只在测试进程中替换 `DescribeCoupon`、`DescribeCouponItemList` 的业务返回;其它 BSS 操作在该测试进程中不开放。覆盖可用余额、余额不足、包年包月限制、GPU 排除、范围未知、过期、满减门槛、商品黑名单与查询失败九种情况。每种情况固定使用测试输入“8 小时 GPU 计算费 240 元”,核对模型表格中的抵扣额、需补现金及继续等待选案的流程状态;这不是 GPU 实时报价或实际结算证明。 + +```bash +uv run python scripts/ci/run_e2e.py --case coupon-budget-mock-a2a --suite live \ + --credential-source-dir /path/to/test-config --text-model glm-5.2-fast-preview +``` + +Mock 入口只位于 `scripts/ci/`,不修改应用运行时,也没有生产环境开关。真实结果与模拟结果使用不同用例名、报告目录及 `coupon_api_mocked` 标记;不可把该用例当成真实账单抵扣通过。 + +CI 调用同一个入口,并用 `--run-dir` 指定报告目录。`fast` 适合快速检查,`full` 适合完整确定性验收。真实场景必须提供测试专用配置目录;包含云写入的用例还需 `--allow-cloud-write`。配置文件内容应通过 CI 的 Secret 注入,且不要进入代码、作业输出或上传附件。 `report.md` 是概要表,`report.html` 可展开每个用例,`summary.json` 和各用例 `ci-result.json` 是结构化详情,`junit.xml` 供 CI 测试报告展示。确定性套件可上传 stdout/stderr;真实套件仅上传筛选后的状态文件,不上传原始日志、凭证、工作目录或场景原始 summary。 @@ -102,7 +149,7 @@ REPL 旧场景的 PTY 等待每 10 秒检查一次,每 60 秒在单用例的 s 先按 `report.md` 找到失败用例与首次失败检查,再看对应的 `ci-result.json`、场景 `summary.json`、日志和源码。Agent 应做有界复现,明确归类为产品缺陷、用例/断言缺陷、环境/凭证故障、云资源清理故障或超时,并写出证据、受影响场景和建议修复。配置的一次自动重跑之外,真实云用例再次复现前先核对残留资源与清理结果。报告里的“初步线索”只是索引,不能替代复盘结论。 -总入口目前登记 185 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 +总入口目前登记 199 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 ### 验收边界 diff --git a/scripts/ci/coupon_fixture_server.py b/scripts/ci/coupon_fixture_server.py new file mode 100644 index 000000000..2ed1b4cf0 --- /dev/null +++ b/scripts/ci/coupon_fixture_server.py @@ -0,0 +1,56 @@ +"""Test-process-only A2A bootstrap: substitute BSS results, preserve the real LLM.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from unittest.mock import patch + +from scripts.ci.coupon_fixtures import CASES, response + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--calls", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + args = parser.parse_args() + from iac_code.cli.main import app + from iac_code.tools.base import ToolResult + from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi + + original = AliyunApi._execute_runtime_unattached + + async def execute(tool, *, api_input, **kwargs): + product = str(api_input.get("product", "")).casefold() + action = str(api_input.get("action", "")) + if product in {"bssopenapi", "bss-openapi", "bss"}: + case = json.loads(args.state.read_text(encoding="utf-8"))["case"] + if case not in CASES: + return ToolResult.error("Unknown coupon fixture") + params = api_input.get("params") or {} + with args.calls.open("a", encoding="utf-8") as out: + out.write(json.dumps({"case": case, "action": action, "mocked": True}) + "\n") + if case == "query_denied": + return ToolResult.error("AccessDenied: not authorized to query the current account's coupons.") + if api_input.get("version") != "2023-09-30": + return ToolResult.error("Coupon fixtures require the documented 2023-09-30 API version.") + if action not in {"DescribeCoupon", "DescribeCouponItemList"}: + return ToolResult.error("Only documented coupon read APIs are supported by this fixture.") + return ToolResult.success(json.dumps(response(case, action, params), ensure_ascii=False)) + if not action.startswith(("Describe", "Get", "List", "Query")): + with args.calls.open("a", encoding="utf-8") as out: + out.write(json.dumps({"blocked_cloud_write": True, "action": action}) + "\n") + return ToolResult.error("Cloud writes are blocked by this read-only test server.") + return await original(tool, api_input=api_input, **kwargs) + + # This entry point is never imported by the application or activated by a production flag. + with patch.object(AliyunApi, "_execute_runtime_unattached", execute): + sys.argv = [sys.argv[0], "a2a", "--config", str(args.config)] + app() + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/coupon_fixtures.py b/scripts/ci/coupon_fixtures.py new file mode 100644 index 000000000..4d113e157 --- /dev/null +++ b/scripts/ci/coupon_fixtures.py @@ -0,0 +1,86 @@ +"""Synthetic BSS business responses for real-model coupon acceptance tests only.""" + +from __future__ import annotations + +import copy +import uuid +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class CouponCase: + title: str + cash: int | None + requires_items: bool = False + + +CASES = { + "applicable": CouponCase("300 元可用余额覆盖 240 元计算费", 0, True), + "balance_60": CouponCase("面值 300、余额 60,需补现金 180", 180, True), + "prepaid_only": CouponCase("仅限包年包月新购,不适用按量账单", 240), + "gpu_excluded": CouponCase("支持 ECS,但明确排除 GPU", 240), + "scope_unknown": CouponCase("只确认 ECS 商品,GPU 条件不足", None, True), + "expired": CouponCase("有余额但已经过期", 240), + "threshold": CouponCase("满 1000 减 300,本次 240 不达门槛", 240), + "ecs_blacklisted": CouponCase("ECS 在不适用商品黑名单", 240, True), + "query_denied": CouponCase("查询权限不足,不能解释为没有券", None), +} + + +def coupon(case: str) -> dict[str, Any]: + index = list(CASES).index(case) + value: dict[str, Any] = { + "CouponId": 9900000000000 + index, + "CouponNo": f"STUDENT-300-{index:02d}", + "CouponType": "VOUCHER", "CouponTypeName": "抵用券", + "Amount": "300.00", "RemainAmount": "300.00", "Currency": "CNY", + "Status": "AVAILABLE", "StartTime": "2026-01-01T00:00Z", "EndTime": "2099-01-01T00:00Z", + "YhOrderTypes": ["阿里云按量付费账单"], "MoneyLimit": "无订单金额限制", + "OrderTimeRule": "按量付费账单无最低使用时长限制", "FirstBuy": False, + "ItemNames": ["云服务器ECS"], "SuitItemType": "white", + "Remark": ( + "学生优惠券。适用于 cn-beijing 地域的 ecs.gn6i-c16g1.4xlarge GPU 实例按量计算费用," + "无首购或活动渠道限制。系统盘、EIP 和公网流量费用不在抵扣范围内。" + ), + } + if case == "balance_60": + value["RemainAmount"] = "60.00" + elif case == "prepaid_only": + value.update(YhOrderTypes=["新购"], OrderTimeRule="仅包年包月新购订单", Remark="仅限包年包月新购。") + elif case == "gpu_excluded": + value["Remark"] = "仅支持非 GPU ECS 按量计算费用;明确排除所有 GPU 实例。" + elif case == "scope_unknown": + value["Remark"] = "学生券;实例规格、GPU、地域及活动渠道限制需另行核实。" + elif case == "expired": + value.update(Status="EXPIRED", StartTime="2019-01-01T00:00Z", EndTime="2020-01-01T00:00Z") + elif case == "threshold": + value.update(CouponType="CERTAIN", CouponTypeName="满减券", MoneyLimit="满1000元减300元") + elif case == "ecs_blacklisted": + value.update(SuitItemType="black", Remark="不适用商品以商品黑名单为准;黑名单优先。") + return value + + +def response(case: str, action: str, params: dict[str, Any]) -> dict[str, Any]: + """Honor the documented IDs and pagination instead of returning a canned page.""" + value = coupon(case) + if action == "DescribeCoupon": + rows = [value] + if params.get("CouponId") is not None and str(params["CouponId"]) != str(value["CouponId"]): + rows = [] + elif action == "DescribeCouponItemList": + rows = [{"Code": "vm", "Name": "云服务器ECS"}] + if str(params.get("CouponId", "")) != str(value["CouponId"]): + rows = [] + else: + raise ValueError("Unsupported coupon fixture action") + page = int(params.get("CurrentPage", 1)) + size = int(params.get("PageSize", 20)) + if page < 1 or not 1 <= size <= 100: + raise ValueError("Invalid coupon fixture pagination") + return { + # Do not expose the scenario name or expected decision in model-visible IDs. + "RequestId": str(uuid.uuid5(uuid.NAMESPACE_URL, f"coupon-test/{case}/{action}/{page}")).upper(), + "CurrentPage": page, "PageSize": size, "TotalCount": len(rows), + "Data": copy.deepcopy(rows[(page - 1) * size:page * size]), + } diff --git a/scripts/ci/run_coupon_budget_a2a.py b/scripts/ci/run_coupon_budget_a2a.py new file mode 100644 index 000000000..e8cbccdb9 --- /dev/null +++ b/scripts/ci/run_coupon_budget_a2a.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Verify coupon guide APIs and period-aware deployment planning with real services. + +Use run_e2e.py for isolated real-LLM HTTP/SSE runs. --api-only exercises every +documented API recipe with the configured cloud identity, without an LLM. +No cloud resource writes, purchases, coupon changes or model fixtures are used. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import re +import sys +import time +from decimal import Decimal +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "src")) + +from scripts.a2a.e2e.common import ( # noqa: E402 + ManagedServer, + _free_port, + _write_server_config, + fetch_pipeline_state, + stream_message, + wait_for_server, +) +from scripts.ci.run_promotional_offer_followup_a2a import ( # noqa: E402 + completion_inputs, + pipeline_facts, + visible_pipeline_text, + walk, +) +from scripts.observability.local_observe.e2e_audit import ObserveCapture # noqa: E402 + +PROMPTS = ( + "在阿里云北京给我推荐一个 GPU 云服务器方案,显存 8–16 GiB,内存至少 32 GiB," + "用于推理测试。请展示方案等我选择,当前不要部署或创建任何云资源。", + "我不是说按小时租吗?只租 8 小时,GPU 显存和内存要求不变。先更新方案估算," + "不要选择方案或进入部署。", + "我有 300 元学生优惠券,想用于刚才这个 GPU 部署方案,能抵扣按量付费吗?" + "我不想再补现金。请查一下当前账号的券,说明哪些适用条件能确认、哪些还不清楚。" + "不要替我领券、用券、下单或部署。", +) + + +def hourly_costs_match(outlines: list[str], details: list[str]) -> bool: + """Priced items need the requested unit; free topology resources have no rate.""" + def hourly(value: str) -> bool: + # Check the stated estimate period, not a later explanation such as "非月费". + period = re.search(r"小时|hours?|月|months?", value, re.I) + return period is not None and period.group().casefold() in {"小时", "hour", "hours"} + + def free(value: str) -> bool: + return bool(re.fullmatch(r"\s*(?:(?:[¥¥]\s*)?0(?:\.0+)?|免费|无费用)(?:\s*[((].*[))])?\s*", value)) + + return bool(outlines) and all(hourly(value) for value in outlines) and all( + hourly(value) or free(value) for value in details + ) + + +async def verify_apis(run_dir: Path) -> dict[str, Any]: + from iac_code.services.providers.aliyun import AliyunCredentials + from iac_code.tools.base import ToolContext + from iac_code.tools.cloud.aliyun.runtime import create_aliyun_runtime_services + + runtime = create_aliyun_runtime_services(cache_dir=run_dir / "api-cache") + runtime.credential_provider = lambda: AliyunCredentials.refresh_oauth_if_needed(AliyunCredentials.load()) + evidence: list[dict[str, Any]] = [] + + async def call(action: str, params: dict[str, Any]) -> dict[str, Any]: + result = await runtime.internal_caller.call( + tool_input={ + "product": "BssOpenApi", "version": "2023-09-30", "action": action, + "region_id": "cn-hangzhou", "params": params, + }, + context=ToolContext(cwd=str(run_dir), pipeline_mode=True), + ) + if result.is_error: + raise RuntimeError(f"{action} did not return a successful business result") + body = json.loads(result.content) + if not isinstance(body.get("Data"), list): + raise ValueError(f"{action} returned an unexpected Data shape") + evidence.append({ + "action": action, "version": "2023-09-30", "page": params["CurrentPage"], + "count": len(body["Data"]), "total": body["TotalCount"], + "fields": sorted({key for item in body["Data"] for key in item}), + }) + return body + + try: + coupons = await call("DescribeCoupon", {"CurrentPage": 1, "PageSize": 20}) + required = { + "CouponId", "CouponType", "CouponTypeName", "Amount", "RemainAmount", "Currency", + "Status", "StartTime", "EndTime", "YhOrderTypes", "MoneyLimit", "OrderTimeRule", + "FirstBuy", "Remark", "ItemNames", "SuitItemType", + } + if not coupons["Data"]: + raise RuntimeError("A real coupon is required to verify the documented fields and item query") + present = {key for coupon in coupons["Data"] for key in coupon} + if not required <= present: + raise ValueError("Documented coupon fields have not all been observed in real responses") + for coupon in coupons["Data"]: + Decimal(coupon["Amount"]) + Decimal(coupon["RemainAmount"]) + if coupons["TotalCount"] > len(coupons["Data"]): + await call("DescribeCoupon", {"CurrentPage": 2, "PageSize": 20}) + items = await call("DescribeCouponItemList", { + "CouponId": int(coupons["Data"][0]["CouponId"]), "CurrentPage": 1, "PageSize": 20, + }) + if not items["Data"] or not all({"Code", "Name"} <= item.keys() for item in items["Data"]): + raise ValueError("A non-empty real coupon item list is required to verify Code and Name") + if items["TotalCount"] > len(items["Data"]): + await call("DescribeCouponItemList", { + "CouponId": int(coupons["Data"][0]["CouponId"]), "CurrentPage": 2, "PageSize": 20, + }) + return {"passed": True, "calls": evidence, "settlement_verified": False} + finally: + await runtime.aclose() + + +def tool_inputs(config_dir: Path) -> list[dict[str, Any]]: + found: dict[str, dict[str, Any]] = {} + for path in sorted((config_dir / "projects").rglob("session.jsonl")): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for node in walk(json.loads(line)): + if node.get("type") == "tool_use": + found[str(node["id"])] = { + "id": node["id"], "name": node.get("name"), "input": node.get("input", {}), + } + return list(found.values()) + + +def successful_coupon_queries(config_dir: Path) -> set[str]: + successful = set() + for path in (config_dir / "projects").rglob("session.jsonl"): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for node in walk(json.loads(line)): + if node.get("type") != "tool_result" or node.get("is_error") is not False: + continue + try: + body = json.loads(node.get("content", "")) + except (ValueError, TypeError): + continue + if isinstance(body, dict) and isinstance(body.get("Data"), list) and "TotalCount" in body: + successful.add(node["tool_use_id"]) + return successful + + +def run_a2a(args: argparse.Namespace) -> dict[str, Any]: + config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) + if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): + raise ValueError("Run through run_e2e.py with isolated configuration") + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + port = _free_port("127.0.0.1") + config = _write_server_config(args.run_dir, host="127.0.0.1", port=port, auto_approve_permissions=True) + observe = ObserveCapture(args.run_dir / "telemetry").start() + env = dict(os.environ) + env.update(observe.env) + env.update({ + "PYTHONPATH": str(REPO / "src"), "IAC_CODE_MODE": "pipeline", + "IAC_CODE_PIPELINE_NAME": "selling_solution_first", "IAC_CODE_ROS_AGENT_AWARENESS": "true", + "IAC_CODE_TELEMETRY_LOCAL_ONLY": "1", "IAC_CODE_PROVIDER": args.provider, + "IAC_CODE_MODEL": args.model, "IAC_CODE_CONFIG_BACKUP_DIR": str(args.run_dir / "backup"), + }) + server = ManagedServer( + python_cmd=[sys.executable], config_path=config, process_cwd=str(REPO), + allowed_cwd=str(workspace), env=env, log_prefix=args.run_dir / "server", + ) + checks: dict[str, bool] = {} + turns = [] + context_id = task_id = "" + try: + server.start() + wait_for_server(f"http://127.0.0.1:{port}", timeout=60, owned_server=server) + for index, prompt in enumerate(PROMPTS): + started = time.monotonic() + before = len(tool_inputs(config_dir)) + summary = stream_message( + server_url=f"http://127.0.0.1:{port}", cwd=str(workspace), prompt=prompt, + context_id=context_id, task_id=task_id, name=f"turn-{index + 1}", + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + ) + context_id, task_id = summary.context_id, summary.task_id + fetch_pipeline_state( + server_url=f"http://127.0.0.1:{port}", context_id=context_id, task_id=task_id, + run_dir=args.run_dir, name=f"turn-{index + 1}", redaction_env=env, + ) + conclusion, steps = pipeline_facts(args.run_dir / f"turn-{index + 1}.events.jsonl") + completions = completion_inputs(config_dir) + conclusion = conclusion or (completions[-1] if completions else {}) + calls = tool_inputs(config_dir)[before:] + outline_costs = [ + str(candidate.get("total_cost", candidate.get("total_monthly_cost", ""))) + for call in calls if call["name"] == "show_architecture_plan" + for candidate in call["input"].get("candidates", []) + ] + detail_costs = [ + str(resource.get("rough_cost", resource.get("rough_monthly_cost", ""))) + for call in calls if call["name"] == "show_candidate_detail" + for resource in call["input"].get("resource_inventory", []) + if resource.get("rough_cost", resource.get("rough_monthly_cost")) + ] + text = visible_pipeline_text(args.run_dir / f"turn-{index + 1}.events.jsonl") + checks[f"turn {index + 1}: real HTTP/SSE response"] = summary.response_content_type == "text/event-stream" + checks[f"turn {index + 1}: remains in deployment planning"] = ( + not summary.normal_handoff_ready and conclusion.get("status") != "rejected" + and "materialize_selected_candidate" not in steps + and summary.last_status_state == "TASK_STATE_INPUT_REQUIRED" + ) + if index == 0: + checks["default estimate is monthly"] = bool(outline_costs) and all( + re.search(r"月|month", cost, re.I) for cost in outline_costs + ) + elif index == 1: + checks["explicit hourly request updates total and details"] = hourly_costs_match( + outline_costs, detail_costs, + ) + checks["eight-hour duration is explained"] = bool(re.search( + r"8\s*(?:个)?\s*小时|8\s*hours?|八小时", text + json.dumps(calls, ensure_ascii=False), re.I, + )) + else: + checks["coupon guide was read on demand"] = any( + call["name"] == "read_file" and "coupons.md" in json.dumps(call["input"]) + for call in calls + ) + checks["coupon inquiry uses a real cloud API"] = any( + call["name"] == "aliyun_api" and call["input"].get("action") == "DescribeCoupon" + and call["id"] in successful_coupon_queries(config_dir) + for call in calls + ) + checks["coupon inquiry does not redirect to assistant"] = "ros.console.aliyun.com/" not in text + turns.append({ + "turn": index + 1, "seconds": round(time.monotonic() - started, 2), + "task_state": summary.last_status_state, "outline_costs": outline_costs, + "detail_costs": detail_costs, "started_steps": steps, + "tools": [call["name"] for call in calls], + }) + print(json.dumps({"model": args.model, **turns[-1]}, ensure_ascii=False), flush=True) + calls = tool_inputs(config_dir) + legacy_cost_fields = {"total_monthly_cost", "rough_monthly_cost", "monthly_range", "monthly_cost"} + checks["planning tool inputs use neutral cost names"] = all( + not legacy_cost_fields.intersection(node) + for call in calls if call["name"] in {"show_architecture_plan", "show_candidate_detail"} + for node in walk(call["input"]) + ) + checks["no cloud writes or deployment tools"] = all( + call["input"].get("action", "").startswith(("Describe", "Get", "List", "Query")) + for call in calls if call["name"] == "aliyun_api" + ) and not any(call["name"].startswith("ros_deploy") for call in calls) + finally: + server.terminate() + records = observe.stop() + for name in (".credentials.yml", ".cloud-credentials.yml", "settings.yml"): + (config_dir / name).unlink(missing_ok=True) + observed = sorted({ + str(record.get("attributes", {}).get("gen_ai.request.model")) + for record in records if record.get("attributes", {}).get("gen_ai.request.model") + }) + checks["requested real model only"] = observed == [args.model] + return {"checks": checks, "turns": turns, "observed_models": observed} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model", default="deepseek-v4.1-flash") + parser.add_argument("--provider", default="dashscope") + parser.add_argument("--python") + parser.add_argument("--timeout", type=float, default=420) + parser.add_argument("--api-only", action="store_true") + parser.add_argument("--allow-real-cloud", action="store_true") + args = parser.parse_args() + if not args.allow_real_cloud: + parser.error("--allow-real-cloud is required") + args.run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + logging.disable(logging.CRITICAL) + from loguru import logger + logger.remove() + result: dict[str, Any] = {"scenario": "coupon-budget-a2a", "cleanup_status": "not-needed"} + try: + result["api_verification"] = asyncio.run(verify_apis(args.run_dir)) + if not args.api_only: + result.update(run_a2a(args)) + result["status"] = "passed" if all(result.get("checks", {}).values()) else "failed" + except Exception as exc: + result.update(status="failed", error_type=type(exc).__name__) + (args.run_dir / "summary.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False), flush=True) + return 0 if result["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/run_coupon_budget_mock_a2a.py b/scripts/ci/run_coupon_budget_mock_a2a.py new file mode 100644 index 000000000..c5946d203 --- /dev/null +++ b/scripts/ci/run_coupon_budget_mock_a2a.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Real HTTP/SSE A2A + real LLM, with only BSS coupon results substituted. + +The 240 CNY compute charge is a test input, not a current GPU quote. Other +read-only cloud calls retain the configured real credential and transport. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from decimal import Decimal +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "src")) + +from scripts.a2a.e2e.common import ( # noqa: E402 + ManagedServer, + _free_port, + _write_server_config, + stream_message, + wait_for_server, +) +from scripts.ci.coupon_fixtures import CASES, coupon # noqa: E402 +from scripts.ci.run_coupon_budget_a2a import tool_inputs # noqa: E402 +from scripts.ci.run_promotional_offer_followup_a2a import ( # noqa: E402 + pipeline_facts, + visible_pipeline_text, +) +from scripts.observability.local_observe.e2e_audit import ObserveCapture # noqa: E402 + +SETUP = ( + "准备在阿里云北京按量租一台 GPU 服务器,指定 ecs.gn6i-c16g1.4xlarge," + "显存 16 GiB、内存 62 GiB,只用 8 小时。请给一个方案让我选择,暂不部署," + "不创建、修改或删除任何云资源。" +) +QUESTION = ( + "继续刚才北京的 GPU 部署方案,我现在换用另一张面值 300 元的学生券,条件可能不同," + "请重新查询当前账号的券和适用商品,不沿用之前的券查询结果。" + "本次只核对 GPU 计算部分:按量使用 8 小时,控制台给出的计算费合计 240 元;" + "请按这 240 元分析用券,不另查价格。系统盘、EIP、公网流量不包括在这 240 元内,其他费用另算。" + "我希望这部分不补现金。请用表格分别列出 GPU 计算费、券的可用余额、" + "本次计算费可抵扣金额、计算部分需补现金、结论依据;" + "无法确认的填写待确认,不要猜测。暂不选择方案、不重新规划、不下单或用券。" +) + + +def has_assistant_reference(text: str) -> bool: + """Recognize console links by their parsed host, not a substring in an arbitrary URL.""" + for url in re.findall(r"https?://[^\s<>()\[\]]+", text): + try: + parsed = urlsplit(url) + if parsed.scheme == "https" and parsed.hostname == "ros.console.aliyun.com": + return True + except ValueError: + continue + return False + + +def answer_cell(text: str, label: str) -> str: + """Accept vertical or horizontal Markdown tables without using unrelated amounts.""" + rows = [ + [cell.strip() for cell in line.strip().strip("|").split("|")] + for line in text.splitlines() if line.strip().startswith("|") + ] + for index, cells in enumerate(rows): + # Only a row immediately above a Markdown separator is a horizontal + # header. A data cell may mention cash without naming the cash column. + if index + 2 >= len(rows) or not all( + re.fullmatch(r"[\s:-]+", item) for item in rows[index + 1] + ): + continue + for column, cell in enumerate(cells): + if re.search(label, cell) and len(rows[index + 2]) > column: + return rows[index + 2][column] + for cells in rows: + if len(cells) >= 2 and re.search(label, cells[0]): + return cells[1] + return "" + + +def cash_cell(text: str) -> str: + return answer_cell(text, r"现金|自付|自费|差价") + + +def amount_matches(cell: str, expected: int | None) -> bool: + uncertainty = re.sub(r"待确认项[::\s]*无|无待确认项", "", cell) + if expected is None: + return bool(re.search(r"待确认|未知|不确定|无法|未确认|不能确认|待核实|如果|若|假设", uncertainty)) + if re.search(r"待确认|未知|不确定|无法确认|未确认", uncertainty) and not re.search(r"预计|估算|预估", cell): + return False + amounts = [Decimal(value.replace(",", "")) for value in re.findall(r"\d[\d,]*(?:\.\d+)?", cell)] + return bool(amounts) and amounts[0] == Decimal(expected) + + +def evaluate_answer(case: str, text: str) -> dict[str, bool]: + cash = CASES[case].cash + coverage = None if cash is None else 240 - cash + return { + "cash calculation or unknown matches evidence": amount_matches(cash_cell(text), cash), + "deduction calculation or unknown matches evidence": amount_matches( + answer_cell(text, r"抵扣金额|抵扣费用"), coverage, + ), + } + + +def recorded_calls(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def run(args: argparse.Namespace) -> dict[str, Any]: + config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) + if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): + raise ValueError("Run through run_e2e.py with isolated configuration") + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + active = args.run_dir / "coupon-fixture-state.json" + ledger = args.run_dir / "coupon-fixture-calls.jsonl" + active.write_text(json.dumps({"case": "applicable"}), encoding="utf-8") + port = _free_port("127.0.0.1") + config = _write_server_config(args.run_dir, host="127.0.0.1", port=port, auto_approve_permissions=True) + observe = ObserveCapture(args.run_dir / "telemetry").start() + env = dict(os.environ) + env.update(observe.env) + env.update({ + "PYTHONPATH": str(REPO / "src"), "IAC_CODE_MODE": "pipeline", + "IAC_CODE_PIPELINE_NAME": "selling_solution_first", "IAC_CODE_ROS_AGENT_AWARENESS": "true", + "IAC_CODE_TELEMETRY_LOCAL_ONLY": "1", "IAC_CODE_PROVIDER": args.provider, + "IAC_CODE_MODEL": args.model, "IAC_CODE_CONFIG_BACKUP_DIR": str(args.run_dir / "backup"), + }) + server = ManagedServer( + python_cmd=[sys.executable], config_path=config, process_cwd=str(REPO), + allowed_cwd=str(workspace), env=env, log_prefix=args.run_dir / "server", + server_args=[ + "-m", "scripts.ci.coupon_fixture_server", "--state", str(active), + "--calls", str(ledger), "--config", str(config), + ], + ) + checks: dict[str, bool] = {} + rows = [] + server_url = f"http://127.0.0.1:{port}" + try: + server.start() + wait_for_server(server_url, timeout=60, owned_server=server) + setup = stream_message( + server_url=server_url, cwd=str(workspace), prompt=SETUP, name="setup", + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + ) + checks["real setup waits for selection"] = ( + setup.last_status_state == "TASK_STATE_INPUT_REQUIRED" and not setup.normal_handoff_ready + ) + if not checks["real setup waits for selection"]: + raise RuntimeError("Real-model setup did not reach the planning selection boundary") + context_id, task_id = setup.context_id, setup.task_id + for case in args.scenario or CASES: + active.write_text(json.dumps({"case": case}), encoding="utf-8") + start = time.monotonic() + before = len(recorded_calls(ledger)) + prior_tool_ids = {call["id"] for call in tool_inputs(config_dir)} + summary = stream_message( + server_url=server_url, cwd=str(workspace), prompt=QUESTION, + context_id=context_id, task_id=task_id, name=case, + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + ) + context_id, task_id = summary.context_id, summary.task_id + path = args.run_dir / f"{case}.events.jsonl" + text = visible_pipeline_text(path) + # Fixtures contain no real user data. Keep the model answer for human review. + (args.run_dir / f"{case}.answer.txt").write_text(text, encoding="utf-8") + conclusion, steps = pipeline_facts(path) + calls = recorded_calls(ledger)[before:] + cell = cash_cell(text) + local = { + "real HTTP/SSE": summary.response_content_type == "text/event-stream", + "visible answer returned for the original question": bool(text.strip()), + "queried current coupon fixture": any( + call.get("case") == case and call.get("action") == "DescribeCoupon" + and call.get("mocked") is True for call in calls + ), + **evaluate_answer(case, text), + "stays in deployment planning": ( + summary.last_status_state == "TASK_STATE_INPUT_REQUIRED" + and not summary.normal_handoff_ready and conclusion.get("status") != "rejected" + and "materialize_selected_candidate" not in steps + ), + "no assistant handoff": not summary.normal_handoff_ready and conclusion.get("status") != "rejected", + } + if CASES[case].requires_items: + local["queried item scope"] = any( + call.get("case") == case and call.get("action") == "DescribeCouponItemList" for call in calls + ) + local["item query uses the current coupon ID"] = any( + call["id"] not in prior_tool_ids and call["name"] == "aliyun_api" + and call["input"].get("action") == "DescribeCouponItemList" + and str(call["input"].get("params", {}).get("CouponId")) == str(coupon(case)["CouponId"]) + and int(call["input"].get("params", {}).get("CurrentPage", 1)) == 1 + for call in tool_inputs(config_dir) + ) + if case == "applicable": + local["compute coverage is quantified"] = "240" in text + local["settlement caveat remains"] = bool(re.search(r"账单|结算|以实际|实际抵扣", text)) + for key, value in local.items(): + checks[f"{case}: {key}"] = value + row = { + "case": case, "title": CASES[case].title, "expected_cash": CASES[case].cash, + "cash_cell": cell, "passed": all(local.values()), "checks": local, + "assistant_reference_present": has_assistant_reference(text), + "seconds": round(time.monotonic() - start, 2), + } + rows.append(row) + print(json.dumps({"model": args.model, **row}, ensure_ascii=False), flush=True) + if summary.normal_handoff_ready: + break + all_calls = recorded_calls(ledger) + checks["no cloud writes reached transport"] = not any(c.get("blocked_cloud_write") for c in all_calls) + checks["all selected cases completed"] = len(rows) == len(args.scenario or CASES) + checks["coupon guide was actually read"] = any( + call["name"] == "read_file" and "coupons.md" in json.dumps(call["input"]) + for call in tool_inputs(config_dir) + ) + finally: + server.terminate() + records = observe.stop() + for name in (".credentials.yml", ".cloud-credentials.yml", "settings.yml"): + (config_dir / name).unlink(missing_ok=True) + observed = sorted({ + str(record.get("attributes", {}).get("gen_ai.request.model")) + for record in records if record.get("attributes", {}).get("gen_ai.request.model") + }) + checks["only requested real model ran"] = observed == [args.model] + return { + "status": "passed" if all(checks.values()) else "failed", "checks": checks, "cases": rows, + "model": args.model, "observed_models": observed, + "coupon_api_mocked": True, "llm_mocked": False, "settlement_verified": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model", default="glm-5.2-fast-preview") + parser.add_argument("--provider", default="dashscope") + parser.add_argument("--python") + parser.add_argument("--scenario", choices=tuple(CASES), action="append") + parser.add_argument("--timeout", type=float, default=420) + parser.add_argument("--allow-real-cloud", action="store_true") + args = parser.parse_args() + if not args.allow_real_cloud: + parser.error("--allow-real-cloud is required for the remaining real read-only APIs and LLM") + args.run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + result: dict[str, Any] = {"scenario": "coupon-budget-mock-a2a", "cleanup_status": "not-needed"} + try: + result.update(run(args)) + except Exception as exc: + result.update(status="failed", error_type=type(exc).__name__) + (args.run_dir / "summary.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False), flush=True) + return 0 if result["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/run_e2e.py b/scripts/ci/run_e2e.py index d26c9bd6f..717104d50 100644 --- a/scripts/ci/run_e2e.py +++ b/scripts/ci/run_e2e.py @@ -26,6 +26,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] CLOUD_REFRESH_SECONDS = 600 +LLM_ONLY_RUNNERS = frozenset({"smoke", "ros_awareness", "promotional_offer_followup"}) SAFE_LIVE_AUDIT_NOTE = re.compile( r"credential audit: source=(?:llm|cloud); " r"location=(?:logs|artifacts|workspace|templates|other); " @@ -90,6 +91,7 @@ from scripts.a2a.e2e.run_recovery_scenarios import MULTIMODAL_SCENARIOS as A2A_MULTIMODAL_SCENARIOS # noqa: E402 from scripts.ci.live_diagnostics import collect_live_diagnostics # noqa: E402 from scripts.ci.model_pool import MULTIMODAL_MODELS, TEXT_MODELS, ModelAssignment, scheduled_cases # noqa: E402 +from scripts.ci.run_ros_agent_awareness import SCENARIOS as ROS_AWARENESS_SCENARIOS # noqa: E402 from scripts.pipeline.e2e.selling_solution_first.run_scenarios import SCENARIOS as SELLING_SCENARIOS # noqa: E402 from scripts.repl.e2e.run_pipeline_scenarios import _SCENARIOS as REPL_PIPELINE_SCENARIOS # noqa: E402 from scripts.repl.e2e.run_pipeline_scenarios import MULTIMODAL_SCENARIOS as REPL_MULTIMODAL_SCENARIOS # noqa: E402 @@ -209,7 +211,15 @@ class CloudCredentialSetupError(RuntimeError): ) for decision in ("allow_once", "deny", "prompt") ) -CASES = FAST_CASES + EXECUTION_CASES + PERMISSION_CASES +CANDIDATE_BATCH_CASES = tuple( + Case( + "ssf-candidate-batch-" + mode, + "scripts/pipeline/e2e/run_candidate_batch_recovery.py", + ("--mode", mode), 90, "full", + ) + for mode in ("same-process", "cold-recovery") +) +CASES = FAST_CASES + EXECUTION_CASES + PERMISSION_CASES + CANDIDATE_BATCH_CASES LIVE_SCRIPT = "scripts/pipeline/e2e/selling_solution_first/run_scenarios.py" SELLING_CIDR_POOLS = tuple(str(pool) for pool in ipaddress.IPv4Network("10.250.0.0/16").subnets(new_prefix=22)) if len(SELLING_SCENARIOS) > len(SELLING_CIDR_POOLS): @@ -287,7 +297,25 @@ def _selling_group(spec: Any) -> str: "repl-aliyun-readonly-canary", "scripts/repl/e2e/run_real_aliyun_contract_canary.py", (), 900, "live", cleanup_grace=60, live_runner="canary", group="readonly", ), +) + tuple( + Case( + "ros-awareness-" + scenario, "scripts/ci/run_ros_agent_awareness.py", + ("--scenario", scenario), 240, "live", live_runner="ros_awareness", group="readonly", + ) + for scenario in ROS_AWARENESS_SCENARIOS ) + ( + Case( + "promotional-offer-followup-a2a", "scripts/ci/run_promotional_offer_followup_a2a.py", (), 1200, "live", + live_runner="promotional_offer_followup", group="readonly", + ), + Case( + "coupon-budget-a2a", "scripts/ci/run_coupon_budget_a2a.py", (), 1800, "live", + live_runner="coupon_budget", group="readonly", + ), + Case( + "coupon-budget-mock-a2a", "scripts/ci/run_coupon_budget_mock_a2a.py", (), 3600, "live", + live_runner="coupon_budget_mock", group="readonly", + ), Case("smoke-a2a-vpc", "scripts/a2a/smoke/test_a2a_vpc.py", (), 800, "live", live_runner="smoke", group="smoke"), Case("smoke-acp-vpc", "scripts/acp/smoke/test_acp_vpc.py", (), 450, "live", live_runner="smoke", group="smoke"), Case( @@ -373,7 +401,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.error("--cloud-credential-python must name an existing Python interpreter") if args.cloud_credential_python is not None and args.cloud_credential_helper is None: parser.error("--cloud-credential-python requires --cloud-credential-helper") - needs_cloud = any(case.live_runner != "smoke" for case in selected) + needs_cloud = any(case.live_runner not in LLM_ONLY_RUNNERS for case in selected) required_files = [".credentials.yml", "settings.yml"] if needs_cloud and args.cloud_credential_helper is None: required_files.append(".cloud-credentials.yml") @@ -396,7 +424,7 @@ def select_cases(args: argparse.Namespace) -> list[Case]: if args.suite == "fast": return list(FAST_CASES) if args.suite == "full": - return list(FAST_CASES + EXECUTION_CASES + PERMISSION_CASES) + return list(FAST_CASES + EXECUTION_CASES + PERMISSION_CASES + CANDIDATE_BATCH_CASES) if args.suite == "live": return list(LIVE_CASES) if args.suite.startswith("live-"): @@ -493,7 +521,10 @@ def _read_summary(case_dir: Path, source: str) -> dict[str, Any] | None: def _live_cleanup_status(case: Case, summary: dict[str, Any] | None) -> str: - if case.live_runner in {"selector", "agui_selector", "canary", "legacy_a2a_readonly", "smoke"}: + readonly_runners = { + "selector", "agui_selector", "canary", "legacy_a2a_readonly", "coupon_budget", "coupon_budget_mock", + } + if case.live_runner in readonly_runners | LLM_ONLY_RUNNERS: return "not-needed" if summary is None: return "unverified" @@ -735,6 +766,7 @@ def _public_live_summary(summary: dict[str, Any] | None, cleanup_status: str | N "text_exit_code", "text_output_length", "smoke_sync_output_length", "smoke_sync_task_count", "smoke_sync_trailing_agent_message_count", "smoke_sync_permission_response_count", + "coupon_visible_answer_followup_count", "cleanup_turn_event_count", "cleanup_turn_cleanup_event_count", "cleanup_target_count", "cleanup_ledger_pending_count", "cleanup_delete_tool_use_count", "cleanup_get_tool_use_count", "cleanup_failure_event_count", "cleanup_delete_http_status", "cleanup_get_http_status", @@ -1479,7 +1511,8 @@ def run_case( ) source_config_dir.mkdir(mode=0o700, exist_ok=True) filenames = ( - (".credentials.yml", "settings.yml") if case.live_runner == "smoke" or cloud_credential_helper is not None + (".credentials.yml", "settings.yml") + if case.live_runner in LLM_ONLY_RUNNERS or cloud_credential_helper is not None else (".credentials.yml", ".cloud-credentials.yml", "settings.yml") ) for filename in filenames: @@ -1493,9 +1526,11 @@ def run_case( command.extend(("--model", model_assignment.model)) if case.live_runner not in {"canary", "agui_selector"}: command.extend(("--provider", "dashscope")) - if case.live_runner != "smoke" and cloud_credential_helper is not None: + if case.live_runner not in LLM_ONLY_RUNNERS and cloud_credential_helper is not None: _prepare_cloud_credentials(cloud_credential_helper, source_config_dir, cloud_credential_python) - if case.live_runner != "smoke": + if case.live_runner in {"ros_awareness", "promotional_offer_followup"}: + command.append("--allow-real-llm") + elif case.live_runner not in LLM_ONLY_RUNNERS: command.append("--allow-real-cloud") if case.live_runner == "selling": command.extend( @@ -1510,7 +1545,9 @@ def run_case( command.extend(("--source-config-dir", str(source_config_dir))) elif case.live_runner == "repl": command.extend(("--source-config-dir", str(source_config_dir))) - if case in FAST_CASES or (case.suite == "live" and case.live_runner not in {"smoke", "agui_selector"}): + if case in FAST_CASES or ( + case.suite == "live" and case.live_runner not in LLM_ONLY_RUNNERS | {"agui_selector"} + ): command.extend(("--python", sys.executable)) case_env = _case_env(case_dir, case, case_user_id) case_env['IAC_CODE_E2E_CASES_DIR'] = str((run_dir / 'runs').resolve()) @@ -1521,7 +1558,7 @@ def run_case( if model_assignment is not None: case_env.update({"IAC_CODE_PROVIDER": "dashscope", "IAC_CODE_MODEL": model_assignment.model}) case_env["IAC_CODE_E2E_DIAGNOSIS_LOCK"] = str(run_dir.resolve() / ".diagnosis-slot") - if case.live_runner == "smoke": + if case.live_runner in LLM_ONLY_RUNNERS: isolated_home = case_dir / "home" isolated_home.mkdir(mode=0o700, exist_ok=True) case_env["HOME"] = str(isolated_home) @@ -1560,7 +1597,11 @@ def refresh_cloud_credentials() -> None: start_new_session=os.name != "nt", creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, ) - if cloud_credential_helper is not None and case.suite == "live" and case.live_runner != "smoke": + if ( + cloud_credential_helper is not None + and case.suite == "live" + and case.live_runner not in LLM_ONLY_RUNNERS + ): refresh_thread = threading.Thread(target=refresh_cloud_credentials, daemon=True) refresh_thread.start() try: diff --git a/scripts/ci/run_promotional_offer_followup_a2a.py b/scripts/ci/run_promotional_offer_followup_a2a.py new file mode 100644 index 000000000..cdbcff66b --- /dev/null +++ b/scripts/ci/run_promotional_offer_followup_a2a.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Verify minimum specs and promotional offer followups over real HTTP/SSE A2A. + +Run via run_e2e.py for private LLM-only configuration. No scripted model or +tool results are injected. Cloud credentials are not supplied to this case. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Any +from urllib.request import Request, urlopen + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "src")) + +from scripts.a2a.debugger import ( # noqa: E402 + A2A_VERSION_HEADERS, + _extract_pipeline_envelopes, + build_task_cancel_payload, +) +from scripts.a2a.e2e.common import ( # noqa: E402 + ManagedServer, + _free_port, + _write_server_config, + fetch_pipeline_state, + stream_message, + wait_for_server, +) +from scripts.observability.local_observe.e2e_audit import ObserveCapture # noqa: E402 + +PROMPTS = ( + "我自己在做一个 agent 小项目,是旅游规划。建议腾讯云或者阿里云," + "这次用阿里云杭州,一台服务器,给我推荐一个方案。" + "算力要求:2核4G(最低2核2G,推荐4G内存以避免运行时内存不足)。" + "系统镜像要选 CentOS,防火墙开放端口:10010,9090,3000,59000,54328,需要公网访问。", + "为什么不能选那个99元的?ecs.e-c1m1.large 热门推荐,经济型 e,2 vCPU,2 GiB," + "76个可用区,X86计算-共享型,¥45.14/月,惠¥19.86/月。", + "我现在只想确认这个99元活动我的账号能不能买、优惠包含什么、从哪里买,暂时不继续部署。", +) +DESTINATION = "https://ros.console.aliyun.com/cn-hangzhou/chat" + + +def walk(value: Any): + if isinstance(value, dict): + yield value + for item in value.values(): + yield from walk(item) + elif isinstance(value, list): + for item in value: + yield from walk(item) + + +def completion_inputs(config_dir: Path) -> list[dict[str, Any]]: + found: dict[str, dict[str, Any]] = {} + paths = (config_dir / "projects").rglob("session.jsonl") + for path in sorted(paths, key=lambda item: item.stat().st_mtime_ns): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for node in walk(json.loads(line)): + if node.get("type") == "tool_use" and node.get("name") == "complete_step": + value = node.get("input", {}).get("conclusion") + if isinstance(value, dict): + found[str(node.get("id", len(found)))] = value + return list(found.values()) + + +def memory_constraints(value: Any) -> list[dict[str, Any]]: + return [ + item + for node in walk(value) + for item in node.get("hard_constraints", []) + if isinstance(item, dict) and str(item.get("property", "")).casefold() in {"memory", "memory_gib"} + ] + + +def visible_pipeline_text(path: Path) -> str: + """Use only user-visible pipeline deltas, excluding tool arguments and thinking.""" + text = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for envelope in _extract_pipeline_envelopes(json.loads(line)): + if envelope.get("eventType") == "text_delta": + value = envelope.get("data", {}).get("text") + if isinstance(value, str): + text.append(value) + return "".join(text) + + +def pipeline_facts(path: Path) -> tuple[dict[str, Any], list[str]]: + conclusion: dict[str, Any] = {} + steps = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for envelope in _extract_pipeline_envelopes(json.loads(line)): + data = envelope.get("data", {}) + value = data.get("normalizedConclusion") or data.get("conclusion") + if isinstance(value, dict) and value.get("status"): + conclusion = value + step = envelope.get("step", {}) + if envelope.get("eventType") == "step_started" and isinstance(step, dict) and step.get("id"): + steps.append(step["id"]) + return conclusion, steps + + +def run(args: argparse.Namespace) -> dict[str, Any]: + config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) + if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): + raise ValueError("Run via run_e2e.py with isolated configuration") + if (config_dir / ".cloud-credentials.yml").exists(): + raise ValueError("This case requires LLM-only configuration") + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + port = _free_port("127.0.0.1") + config = _write_server_config(args.run_dir, host="127.0.0.1", port=port, auto_approve_permissions=True) + observe = ObserveCapture(args.run_dir / "telemetry").start() + env = dict(os.environ) + env.update(observe.env) + env.update({ + "PYTHONPATH": str(REPO / "src"), + "IAC_CODE_MODE": "pipeline", + "IAC_CODE_PIPELINE_NAME": "selling_solution_first", + "IAC_CODE_ROS_AGENT_AWARENESS": "true", + "IAC_CODE_TELEMETRY_LOCAL_ONLY": "1", + "IAC_CODE_PROVIDER": args.provider or "dashscope", + "IAC_CODE_MODEL": args.model, + "IAC_CODE_CONFIG_BACKUP_DIR": str(args.run_dir / "backup"), + }) + server = ManagedServer( + python_cmd=[sys.executable], config_path=config, process_cwd=str(REPO), + allowed_cwd=str(workspace), env=env, log_prefix=args.run_dir / "server", + ) + checks: dict[str, bool] = {} + turns = [] + context_id = task_id = "" + try: + server.start() + wait_for_server(f"http://127.0.0.1:{port}", timeout=60, owned_server=server) + for index, prompt in enumerate(PROMPTS): + started = time.monotonic() + canceled_unselected_implementation = False + + def stop_unselected_implementation(event: Any) -> None: + nonlocal canceled_unselected_implementation + if index != 1 or canceled_unselected_implementation: + return + for envelope in _extract_pipeline_envelopes(event): + if ( + envelope.get("eventType") == "step_started" + and envelope.get("step", {}).get("id") == "materialize_selected_candidate" + ): + request = Request( + f"http://127.0.0.1:{port}/", + data=json.dumps(build_task_cancel_payload(task_id=task_id)).encode("utf-8"), + headers={"Content-Type": "application/json", **A2A_VERSION_HEADERS}, + ) + with urlopen(request, timeout=30) as response: + canceled_unselected_implementation = not bool(json.load(response).get("error")) + + summary = stream_message( + server_url=f"http://127.0.0.1:{port}", cwd=str(workspace), prompt=prompt, + context_id=context_id, task_id=task_id, name=f"turn-{index + 1}", + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + on_event=stop_unselected_implementation, + ) + context_id, task_id = summary.context_id, summary.task_id + state = fetch_pipeline_state( + server_url=f"http://127.0.0.1:{port}", context_id=context_id, task_id=task_id, + run_dir=args.run_dir, name=f"turn-{index + 1}", redaction_env=env, + ) + completions = completion_inputs(config_dir) + latest, steps = pipeline_facts(args.run_dir / f"turn-{index + 1}.events.jsonl") + latest = latest or (completions[-1] if completions else {}) + constraints = memory_constraints(latest or state) + evidence = { + "turn": index + 1, "seconds": round(time.monotonic() - started, 2), + "http_sse": summary.response_content_type == "text/event-stream", + "task_state": summary.last_status_state, + "pipeline_handoff": summary.normal_handoff_ready, + "step": summary.last_input_required_step_id, + "assistant_link_visible": DESTINATION in visible_pipeline_text( + args.run_dir / f"turn-{index + 1}.events.jsonl" + ), + "completion_status": latest.get("status"), + "memory_constraints": constraints, + "started_steps": steps, + "unselected_implementation_canceled": canceled_unselected_implementation, + } + turns.append(evidence) + checks[f"turn {index + 1}: production HTTP/SSE"] = evidence["http_sse"] + checks[f"turn {index + 1}: no failed task"] = summary.last_status_state not in { + "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "", + } + if index == 0: + checks["minimum 2 GiB remains the lower bound"] = bool(constraints) and all( + item.get("operator") == "gte" and item.get("value") == 2 for item in constraints + ) + checks["initial deployment remains in Step 1"] = ( + summary.last_input_required_step_id == "solution_planning_and_selection" + and not summary.normal_handoff_ready + ) + elif index == 1: + checks["activity followup offers the same-region assistant"] = evidence["assistant_link_visible"] + checks["activity followup saves the linked handoff"] = ( + latest.get("status") == "rejected" and DESTINATION in latest.get("rejection_reason", "") + ) + checks["activity followup does not advance to implementation"] = ( + "materialize_selected_candidate" not in steps and latest.get("status") != "selected" + ) + else: + checks["explicit activity inquiry has a visible assistant link"] = evidence["assistant_link_visible"] + checks["explicit activity handoff saves the linked reply"] = ( + latest.get("status") == "rejected" and DESTINATION in latest.get("rejection_reason", "") + ) + print(json.dumps({"model": args.model, **evidence}, ensure_ascii=False), flush=True) + if summary.normal_handoff_ready: + context_id = task_id = "" + finally: + server.terminate() + records = observe.stop() + checks["server stopped"] = server.process is not None and server.process.poll() is not None + for name in (".credentials.yml", "settings.yml"): + (config_dir / name).unlink(missing_ok=True) + checks["credential copies removed"] = not (config_dir / ".credentials.yml").exists() + observed = sorted({ + str(record.get("attributes", {}).get("gen_ai.request.model")) + for record in records + if record.get("attributes", {}).get("gen_ai.request.model") + }) + checks["only the requested real model ran"] = observed == [args.model] + return { + "scenario": "promotional-offer-followup-a2a", "model": args.model, + "status": "passed" if all(checks.values()) else "failed", + "cleanup_status": "not-needed", "checks": checks, "turns": turns, + "observed_models": observed, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model", required=True) + parser.add_argument("--provider", default="dashscope") + parser.add_argument("--allow-real-llm", action="store_true") + parser.add_argument("--timeout", type=float, default=300) + args = parser.parse_args() + if not args.allow_real_llm: + parser.error("--allow-real-llm is required") + args.run_dir.mkdir(parents=True, exist_ok=True) + try: + summary = run(args) + except Exception as exc: + summary = { + "scenario": "promotional-offer-followup-a2a", "model": args.model, "status": "failed", + "cleanup_status": "not-needed", "error_type": type(exc).__name__, + } + (args.run_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False), flush=True) + return 0 if summary["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/run_ros_agent_awareness.py b/scripts/ci/run_ros_agent_awareness.py new file mode 100644 index 000000000..7afe1b9ed --- /dev/null +++ b/scripts/ci/run_ros_agent_awareness.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Exercise Step 1 routing with a real LLM, production tools and session storage. + +This stops at handoff or the first clarification boundary. It does not replace +model responses or call cloud APIs; transport/UI behavior is outside its scope. +Invoke through run_e2e.py to receive isolated LLM-only configuration. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import time +import uuid +from contextlib import aclosing +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Scenario: + prompt: str + region: str | None = "cn-beijing" + enabled: bool = True + handoff: bool = True + + +SCENARIOS = { + "promotions": Scenario("阿里云现在有什么优惠活动和优惠券?我只想咨询,暂时不部署资源。"), + "renewal": Scenario("已有的 ECS 快到期了,续费有折扣吗?"), + "billing": Scenario("按量付费的 ECS 是如何计费的?欠费后怎么办?"), + "billing-explicit": Scenario("我只咨询已有 ECS 的按量账单,不打算部署或创建资源。按量付费如何计费,欠费后怎么办?"), + "gpu-availability": Scenario("北京地域的 GPU 实例现在有哪些库存和单价?暂时不部署。", region="cn-shanghai"), + "traffic-offset": Scenario("有没有能抵扣已有 ECS 公网流量费的流量包?"), + "default-region": Scenario("我只想咨询阿里云的续费优惠,暂时不创建资源。", region=None), + "disabled": Scenario("我只想咨询已有 ECS 的续费折扣。", enabled=False, handoff=False), + "deployment-pricing": Scenario( + "我想在阿里云部署一个网站,请先问清网站类型和访问规模再推荐方案,选型时也帮我比较按量与包月成本。", + handoff=False, + ), +} + + +async def run(args: argparse.Namespace) -> dict: + # Prefer this checkout even if the interpreter has another editable install. + repo = Path(__file__).resolve().parents[2] + sys.path.insert(0, str(repo / "src")) + from iac_code.config import get_active_provider_key, get_provider_config, load_credentials + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + from iac_code.providers.manager import ProviderManager + from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential, use_aliyun_credential + from iac_code.services.session_storage import SessionStorage + from iac_code.tools.base import ToolRegistry + from iac_code.types.stream_events import ( + AskUserQuestionEvent, + ErrorEvent, + MessageEndEvent, + TextDeltaEvent, + ToolResultEvent, + ToolUseStartEvent, + ) + + scenario = SCENARIOS[args.scenario] + os.environ["IAC_CODE_ROS_AGENT_AWARENESS"] = "true" if scenario.enabled else "false" + os.environ["IAC_CODE_CONFIG_BACKUP_DIR"] = str(args.run_dir / "backup") + provider = args.provider or get_active_provider_key() + if not provider: + raise ValueError("No configured LLM provider") + config = get_provider_config(provider) + model = args.model or config.get("model") + if not model: + raise ValueError("No configured LLM model") + manager = ProviderManager( + model=model, + credentials=load_credentials(model), + provider_key_override=provider, + provider_config_override={**config, "modelFallbackEnabled": False}, + ignore_llm_source=True, + ) + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + flow = PipelineRunner( + pipeline_dir=repo / "src/iac_code/pipeline/selling_solution_first", + provider_manager=manager, + # Step-local tools are registered by the production engine. Deliberately + # omit cloud tools: routing needs no cloud operation or cloud credential. + base_tool_registry=ToolRegistry(), + session_storage=SessionStorage(args.run_dir / "projects"), + session_id="ros-awareness-e2e-" + uuid.uuid4().hex, + cwd=str(workspace), + ) + text: list[str] = [] + tools: list[str] = [] + input_tokens = output_tokens = error_count = 0 + asked_question = False + credential = AliyunCredential(region_id=scenario.region) if scenario.region is not None else None + with use_aliyun_credential(credential): + async with aclosing(flow.run(scenario.prompt)) as events: + async for event in events: + if isinstance(event, TextDeltaEvent): + text.append(event.text) + elif isinstance(event, ToolUseStartEvent): + tools.append(event.name) + elif isinstance(event, MessageEndEvent): + input_tokens += event.usage.total_input_tokens + output_tokens += event.usage.output_tokens + elif isinstance(event, ErrorEvent) or isinstance(event, ToolResultEvent) and event.is_error: + error_count += 1 + elif isinstance(event, AskUserQuestionEvent): + asked_question = True + await flow.persist_pending_ask_user_question(event) + break + + visible = "".join(text) + conclusion = flow.context.get_conclusion("solution_selection") or {} + url = f"https://ros.console.aliyun.com/{scenario.region or DEFAULT_REGION}/chat" + checks = { + "real model returned token usage": input_tokens > 0 and output_tokens > 0, + "no model or tool errors": error_count == 0, + "no deployment selected": flow.context.get_conclusion("selected_plan") is None, + } + if scenario.handoff: + checks.update( + { + "same-region assistant link is visible": url in visible, + "only the expected assistant destination is used": visible.count("https://ros.console.aliyun.com/") + == visible.count(url) + > 0, + "guidance is saved in the early-exit conclusion": conclusion.get("status") == "rejected" + and conclusion.get("continue_pipeline") is False + and url in conclusion.get("rejection_reason", ""), + "handoff skips deployment questions and candidates": tools == ["complete_step"] and not asked_question, + } + ) + elif scenario.enabled: + checks.update( + { + "normal clarification remains available": asked_question and "ask_user_question" in tools, + "no assistant handoff": "https://ros.console.aliyun.com/" not in visible + and conclusion.get("status") != "rejected", + } + ) + else: + # The unchanged base flow permits clarification or a normal rejection + # when the user has already confirmed that no deployment is requested. + checks.update( + { + "disabled flow preserves clarification or normal early exit": asked_question + and "ask_user_question" in tools + or conclusion.get("status") == "rejected" + and bool(conclusion.get("rejection_reason")), + "disabled flow does not add an assistant destination": "https://ros.console.aliyun.com/" not in visible + and "https://ros.console.aliyun.com/" not in conclusion.get("rejection_reason", ""), + } + ) + return { + "scenario": args.scenario, + "status": "passed" if all(checks.values()) else "failed", + "cleanup_status": "not-needed", + "checks": checks, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scenario", choices=SCENARIOS, required=True) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model") + parser.add_argument("--provider") + parser.add_argument("--allow-real-llm", action="store_true") + parser.add_argument("--timeout", type=float, default=180) + args = parser.parse_args(argv) + if not args.allow_real_llm: + parser.error("This calls a real LLM; pass --allow-real-llm") + args.run_dir.mkdir(parents=True, exist_ok=True) + started = time.monotonic() + try: + summary = asyncio.run(asyncio.wait_for(run(args), timeout=args.timeout)) + except Exception as exc: + # Do not copy provider messages/HTTP payloads into CI reports. + summary = { + "scenario": args.scenario, + "status": "failed", + "cleanup_status": "not-needed", + "checks": {"scenario completed within budget": False}, + "error_type": type(exc).__name__, + } + summary["duration_seconds"] = round(time.monotonic() - started, 2) + (args.run_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + print(json.dumps(summary), flush=True) + return 0 if summary["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pipeline/e2e/run_candidate_batch_recovery.py b/scripts/pipeline/e2e/run_candidate_batch_recovery.py new file mode 100644 index 000000000..2cd6d3abc --- /dev/null +++ b/scripts/pipeline/e2e/run_candidate_batch_recovery.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Run the deterministic P18 selection regression through the shared CI entrypoint.""" + +from __future__ import annotations + +import argparse +import json +import os +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--mode", choices=("same-process", "cold-recovery"), required=True) + args = parser.parse_args() + run_dir = args.run_dir.resolve() + run_dir.mkdir(parents=True, exist_ok=True) + os.chdir(Path(__file__).resolve().parents[3]) + junit_path = run_dir / "pytest.xml" + node = "tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py::" + node += f"test_idempotent_outline_survives_selection_resume[{args.mode}]" + exit_code = pytest.main( + [ + "-q", + "--tb=short", + "-o", + "addopts=", + node, + "--basetemp=" + str(run_dir / "pytest"), + "--junitxml=" + str(junit_path), + ] + ) + try: + cases = ET.parse(junit_path).findall(".//testcase") + completed = len(cases) == 1 and not any( + case.find(tag) is not None for case in cases for tag in ("failure", "error", "skipped") + ) + except (OSError, ET.ParseError): + completed = False + passed = exit_code == 0 and completed + summary = { + "passed": passed, + "mode": args.mode, + "checks": {"unchanged candidate batch survives real pipeline persistence and selection": passed}, + } + (run_dir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.py b/setup.py index 521b41085..6d66c86ef 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,7 @@ "iac-aliyun-deploying", ) SELLING_SOLUTION_FIRST_REFERENCE_SKILLS = ( + "iac-aliyun-solution-first", "iac-aliyun-deploying", "iac-aliyun-materialize-selected-candidate", ) diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index 299044be2..77046d0aa 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -17,6 +17,7 @@ from a2a.server.events.event_queue_v2 import QueueShutDown from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.routes import create_jsonrpc_routes +from a2a.server.tasks import TaskManager from a2a.server.tasks.inmemory_task_store import DEFAULT_LIST_TASKS_PAGE_SIZE, decode_page_token, encode_page_token from a2a.types import ( CancelTaskRequest, @@ -138,10 +139,15 @@ class _ASGIResponseChunk: class _DetachedPermissionEventQueue(EventQueue): """Minimal producer queue for resuming an already-persisted A2A task.""" - def __init__(self, queue: asyncio.Queue[Any]) -> None: + def __init__(self, queue: asyncio.Queue[Any], task_manager: TaskManager) -> None: self._queue = queue + self._task_manager = task_manager async def enqueue_event(self, event: Any) -> None: + # This direct continuation bypasses DefaultRequestHandler's consumer. + # Keep GetTask in sync even when a nonstream caller discards the events + # or the response disconnects after its decision was committed. + await self._task_manager.process(event) await self._queue.put(event) @@ -1014,7 +1020,14 @@ async def _on_inactive_input_response_send_stream(self, params: SendMessageReque async def run_permission_response() -> None: nonlocal request_context try: - event_queue = _DetachedPermissionEventQueue(queue) + task_manager = TaskManager( + task_id=task.id, + context_id=task.context_id, + task_store=self.task_store, + context=context, + initial_message=None, + ) + event_queue = _DetachedPermissionEventQueue(queue, task_manager) RecoverableInputAdmissionCarrier.acknowledge_enqueued(request_context, asyncio.current_task()) try: await self.agent_executor.execute(request_context, event_queue) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index c506cc0a7..b61ea3a01 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -3049,6 +3049,10 @@ def _tool_result_block_metadata(processed: Any, result_metadata: dict[str, Any] ) from iac_code.tools.cloud.base_stack import persisted_stack_metadata + # Idempotent display calls retain the original batch across transcript recovery. + candidate_set_id = result_metadata.get("candidate_set_id") + if isinstance(candidate_set_id, str) and candidate_set_id: + metadata["candidate_set_id"] = candidate_set_id aliyun_http = sanitize_aliyun_http_metadata(result_metadata.get(ALIYUN_HTTP_METADATA_KEY)) if aliyun_http is not None: metadata[ALIYUN_HTTP_METADATA_KEY] = aliyun_http diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 79847ea92..33c62b865 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -6158,14 +6158,14 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Zeigen Sie vor der Erzeugung ausführlicher Details einen vollständigen " "Satz kompakter Kandidatenübersichten an. Übermitteln Sie jeden aktuellen " -"Kandidaten der Reihe nach mit Name, Zusammenfassung, monatlicher " -"Schätzung und wichtigster Abwägung. Fügen Sie keine Topologieknoten, " -"Ressourcenbestände oder detaillierten Kostenpositionen hinzu." +"Kandidaten der Reihe nach mit Name, Zusammenfassung, Kostenschätzung und " +"wichtigster Abwägung. Fügen Sie keine Topologieknoten, Ressourcenbestände" +" oder detaillierten Kostenpositionen hinzu." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -6184,8 +6184,12 @@ msgid "Short product combination and architecture summary" msgstr "Kurze Zusammenfassung der Produktkombination und Architektur" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Ungefähre monatliche Spanne, z. B. ¥230~¥380/Monat" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Ungefähre Kostenspanne mit Zeitraum: standardmäßig monatlich oder in der " +"vom Benutzer gewünschten Zeiteinheit und Dauer." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6194,10 +6198,10 @@ msgstr "Wichtigste Abwägung bei Kosten, Verfügbarkeit oder Komplexität" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates muss ein nicht leeres Array eindeutiger Übersichten mit " -"candidate_name, summary, total_monthly_cost und key_tradeoff sein" +"candidate_name, summary, total_cost und key_tradeoff sein" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -6296,14 +6300,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Zeigen Sie die vollständigen Details für genau einen Kandidaten aus dem " "neuesten show_architecture_plan-Satz. Rufen Sie die Funktion einmal pro " "Modellrunde in der Reihenfolge der Kandidatenindizes auf. Geben Sie " "Ressourcenlebenszyklus, Topologiegraf, Ressourcenbestand, Kostenannahmen " "und Entscheidungshinweise an; wiederholen Sie weder Zusammenfassung noch " -"Monatssumme." +"Gesamtkosten." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7684,11 +7688,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Die Alibaba-Cloud-API-Metadaten für {operation} sind vorübergehend nicht " -"verfügbar. Versuchen Sie es später erneut." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Die Alibaba-Cloud-API-Metadaten für {operation} sind vorübergehend nicht verfügbar. Wiederhole denselben Tool-Aufruf nach kurzer Wartezeit einmal. Falls er erneut fehlschlägt, melde die vorübergehende Störung, statt die API als nicht unterstützt einzustufen." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po index e9a01e43d..10dbe8a6d 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po @@ -4342,3 +4342,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "Der Link konnte nicht geöffnet werden. Bitte erneut versuchen." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Geschätzte Kosten" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 023139453..9977637a2 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -6113,13 +6113,14 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Muestra un lote completo de resúmenes ligeros de candidatos antes de " "generar los detalles. Envía cada candidato actual en orden con su nombre," -" resumen, estimación mensual y principal contrapartida. No incluyas nodos" -" de topología, inventario de recursos ni partidas de coste detalladas." +" resumen, estimación de costes y principal contrapartida. No incluyas " +"nodos de topología, inventario de recursos ni partidas de coste " +"detalladas." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -6138,8 +6139,12 @@ msgid "Short product combination and architecture summary" msgstr "Resumen breve de la combinación de productos y la arquitectura" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Intervalo mensual aproximado, por ejemplo ¥230~¥380/mes" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Intervalo de costes aproximado con su período: mensual por defecto, o " +"según la unidad y la duración solicitadas por el usuario." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6148,10 +6153,10 @@ msgstr "La contrapartida más importante de coste, disponibilidad o complejidad" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates debe ser un array no vacío de resúmenes únicos con " -"candidate_name, summary, total_monthly_cost y key_tradeoff" +"candidate_name, summary, total_cost y key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -6247,14 +6252,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Muestra los detalles completos de exactamente un candidato del último " "lote de show_architecture_plan. Llama una vez por turno del modelo " "siguiendo el orden del índice de candidatos. Incluye la intención del " "ciclo de vida de los recursos, el grafo de topología, el inventario de " "recursos, los supuestos de coste y las notas de decisión; no repitas el " -"resumen ni el total mensual." +"resumen ni el coste total." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7630,11 +7635,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Los metadatos de la API de Alibaba Cloud para {operation} no están " -"disponibles temporalmente. Vuelva a intentarlo más tarde." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Los metadatos de la API de Alibaba Cloud para {operation} no están disponibles temporalmente. Espera un momento y vuelve a realizar la misma llamada a la herramienta una vez; si vuelve a fallar, informa del fallo temporal en lugar de considerar que la API no es compatible." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po index c23ddb40a..aab10fa94 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po @@ -4331,3 +4331,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "No se pudo abrir el enlace. Inténtalo de nuevo." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Coste estimado" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 128abf20e..efaebf5d5 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -6120,12 +6120,12 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Affichez un lot complet de résumés légers des candidats avant de générer " "les détails. Soumettez chaque candidat actuel dans l’ordre avec son nom, " -"son résumé, son estimation mensuelle et son principal compromis. " +"son résumé, son estimation des coûts et son principal compromis. " "N’incluez ni nœuds de topologie, ni inventaire de ressources, ni postes " "de coût détaillés." @@ -6146,8 +6146,12 @@ msgid "Short product combination and architecture summary" msgstr "Bref résumé de la combinaison de produits et de l’architecture" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Fourchette mensuelle approximative, par exemple ¥230~¥380/mois" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Fourchette de coûts approximative avec sa période : mensuelle par défaut," +" ou selon l’unité et la durée demandées par l’utilisateur." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6158,10 +6162,10 @@ msgstr "" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates doit être un tableau non vide de résumés uniques contenant " -"candidate_name, summary, total_monthly_cost et key_tradeoff" +"candidate_name, summary, total_cost et key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -6260,14 +6264,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Affichez les détails complets d’un seul candidat du dernier lot " "show_architecture_plan. Effectuez un appel par tour de modèle dans " "l’ordre des index de candidats. Incluez l’intention de cycle de vie des " "ressources, le graphe de topologie, l’inventaire des ressources, les " "hypothèses de coût et les notes de décision ; ne répétez ni le résumé ni " -"le total mensuel." +"le coût total." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7649,11 +7653,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Les métadonnées de l'API Alibaba Cloud pour {operation} sont " -"temporairement indisponibles. Réessayez plus tard." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Les métadonnées de l’API Alibaba Cloud pour {operation} sont temporairement indisponibles. Réessayez une fois le même appel d’outil après une courte attente ; en cas de nouvel échec, signalez une panne temporaire plutôt que de considérer l’API comme non prise en charge." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po index 2c0505d26..9681be0d9 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po @@ -4345,3 +4345,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "Impossible d’ouvrir le lien. Veuillez réessayer." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Coût estimé" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 65207836b..10505ae40 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -5753,9 +5753,9 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." -msgstr "詳細情報を生成する前に、軽量な候補概要を完全な1バッチとして表示します。現在の各候補を順番に、名前、概要、月額見積もり、主なトレードオフとともに送信してください。トポロジーノード、リソース一覧、詳細な費用項目は含めないでください。" +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." +msgstr "詳細情報を生成する前に、軽量な候補概要を完全な1バッチとして表示します。現在の各候補を順番に、名前、概要、費用見積もり、主なトレードオフとともに送信してください。トポロジーノード、リソース一覧、詳細な費用項目は含めないでください。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -5772,8 +5772,10 @@ msgid "Short product combination and architecture summary" msgstr "製品構成とアーキテクチャの簡潔な概要" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "月額のおおよその範囲(例: ¥230~¥380/月)" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "期間を含む概算費用の範囲。既定では月額とし、ユーザーが時間単位と利用期間を指定した場合はそれに従います。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -5782,9 +5784,9 @@ msgstr "コスト、可用性、または複雑性に関する最も重要なト #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" -"candidates は candidate_name、summary、total_monthly_cost、key_tradeoff " +"candidates は candidate_name、summary、total_cost、key_tradeoff " "を含む、一意な概要の空でない配列である必要があります" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py @@ -5878,10 +5880,10 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "最新の show_architecture_plan " -"バッチから候補を1つだけ選び、その詳細を表示します。モデルの各ターンで候補インデックス順に1回ずつ呼び出してください。リソースのライフサイクル意図、トポロジーグラフ、リソース一覧、費用の前提、判断メモを含め、概要や月額合計は繰り返さないでください。" +"バッチから候補を1つだけ選び、その詳細を表示します。モデルの各ターンで候補インデックス順に1回ずつ呼び出してください。リソースのライフサイクル意図、トポロジーグラフ、リソース一覧、費用の前提、判断メモを含め、概要や費用合計は繰り返さないでください。" #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7198,9 +7200,10 @@ msgstr "Alibaba Cloud API {operation} が見つかりません。プロダクト #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "{operation} の Alibaba Cloud API メタデータは一時的に利用できません。後でもう一度お試しください。" +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "{operation} の Alibaba Cloud API メタデータは一時的に利用できません。少し待ってから同じツール呼び出しを一度再試行してください。それでも失敗する場合は、API がサポートされていないと判断せず、一時的な障害として報告してください。" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po index 020528f08..7199582dc 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po @@ -4180,3 +4180,7 @@ msgstr "ローカルランタイムを再起動できませんでした。詳細 #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "リンクを開けませんでした。もう一度お試しください。" + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "概算費用" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 9c19de080..a9de26615 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -6064,13 +6064,13 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Exiba um lote completo de resumos leves dos candidatos antes de gerar os " "detalhes. Envie cada candidato atual em ordem, com nome, resumo, " -"estimativa mensal e principal contrapartida. Não inclua nós de topologia," -" inventário de recursos nem itens de custo detalhados." +"estimativa de custo e principal contrapartida. Não inclua nós de " +"topologia, inventário de recursos nem itens de custo detalhados." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -6089,8 +6089,12 @@ msgid "Short product combination and architecture summary" msgstr "Resumo breve da combinação de produtos e da arquitetura" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Faixa mensal aproximada, por exemplo ¥230~¥380/mês" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Faixa de custo aproximada com seu período: mensal por padrão, ou conforme" +" a unidade e a duração solicitadas pelo usuário." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6099,10 +6103,10 @@ msgstr "A contrapartida mais importante de custo, disponibilidade ou complexidad #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates deve ser um array não vazio de resumos exclusivos com " -"candidate_name, summary, total_monthly_cost e key_tradeoff" +"candidate_name, summary, total_cost e key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -6199,14 +6203,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Exiba os detalhes completos de exatamente um candidato do lote mais " "recente de show_architecture_plan. Faça uma chamada por turno do modelo " "na ordem dos índices dos candidatos. Inclua a intenção do ciclo de vida " "dos recursos, o grafo de topologia, o inventário de recursos, as " -"premissas de custo e as notas de decisão; não repita o resumo nem o total" -" mensal." +"premissas de custo e as notas de decisão; não repita o resumo nem o custo" +" total." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7569,11 +7573,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Os metadados da API do Alibaba Cloud para {operation} estão " -"temporariamente indisponíveis. Tente novamente mais tarde." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Os metadados da API do Alibaba Cloud para {operation} estão temporariamente indisponíveis. Aguarde um pouco e repita a mesma chamada à ferramenta uma vez; se falhar novamente, informe a falha temporária em vez de considerar a API incompatível." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po index 19990d106..07981bd3d 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po @@ -4317,3 +4317,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "Não foi possível abrir o link. Tente novamente." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Custo estimado" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 3f0e0b854..2ac6d6a56 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -5681,9 +5681,9 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." -msgstr "在生成详细信息前,先一次性展示一批完整的轻量候选方案概览。按顺序提交当前每个候选方案的名称、摘要、月度估价和关键取舍。不要包含拓扑节点、资源清单或详细费用项。" +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." +msgstr "在生成详细信息前,先一次性展示一批完整的轻量候选方案概览。按顺序提交当前每个候选方案的名称、摘要、费用估算和关键取舍。不要包含拓扑节点、资源清单或详细费用项。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -5700,8 +5700,10 @@ msgid "Short product combination and architecture summary" msgstr "简短的产品组合与架构摘要" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "粗略月费范围,例如 ¥230~¥380/月" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "包含计费周期的粗略费用范围:默认按月,或按用户要求的时间单位和时长。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -5710,10 +5712,10 @@ msgstr "最重要的成本、可用性或复杂度取舍" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates 必须是非空且不重名的候选方案概览数组,每项包含 " -"candidate_name、summary、total_monthly_cost 和 key_tradeoff" +"candidate_name、summary、total_cost 和 key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -5805,10 +5807,10 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "仅展示最新 show_architecture_plan " -"批次中一个候选方案的完整详情。每个模型轮次按候选方案索引顺序调用一次。包括资源生命周期意图、拓扑图、资源清单、费用假设和决策说明;不要重复摘要或月度总价。" +"批次中一个候选方案的完整详情。每个模型轮次按候选方案索引顺序调用一次。包括资源生命周期意图、拓扑图、资源清单、费用假设和决策说明;不要重复摘要或总费用。" #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7101,9 +7103,10 @@ msgstr "未找到阿里云 API {operation}。请检查产品、版本和操作 #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "{operation} 的阿里云 API 元数据暂时不可用,请稍后重试。" +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "{operation} 的阿里云 API 元数据暂时不可用。请稍等后重试同一工具调用一次;若仍失败,请报告临时故障,不要判断为该 API 不受支持。" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po index ac4fa5d9c..d0b0fb6dd 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po @@ -4172,3 +4172,7 @@ msgstr "本地运行时无法重新启动。请打开诊断目录查看详情。 #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "无法打开链接,请重试。" + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "费用估算" diff --git a/src/iac_code/pipeline/engine/loader.py b/src/iac_code/pipeline/engine/loader.py index 6ed537c81..7e5e455ed 100644 --- a/src/iac_code/pipeline/engine/loader.py +++ b/src/iac_code/pipeline/engine/loader.py @@ -503,6 +503,8 @@ def _bind_hooks(steps: list[StepSpec], pipeline_dir: Path) -> None: module = _load_module_from_file(hook_path, f"pipeline_hook_{step.step_id}") if hasattr(module, "on_enter"): step.on_enter = module.on_enter + if hasattr(module, "build_prompt_context"): + step.prompt_context_builder = module.build_prompt_context if hasattr(module, "on_exit"): step.on_exit = module.on_exit if hasattr(module, "enrich_completion_input"): diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index 90c0a375b..1a25e00f6 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -841,16 +841,19 @@ def _build_full_system_prompt(self, step: StepSpec, context: PipelineContext) -> prompt_file = step.prompt_file_for_surface(self._surface) prompt_path = self._pipeline_dir / prompt_file step_prompt = prompt_path.read_text(encoding="utf-8") if prompt_file else "" + runtime_context = step.prompt_context_builder() if step.prompt_context_builder is not None else {} rendered_step_prompt = render_prompt( step_prompt, context, step.context_fields, - extra_context={"step_config": step.config}, + extra_context={"step_config": step.config, **runtime_context}, ) skill_content = "" if step.skill: skill_content = self._resolve_skill_prompt(step.skill) or "" + if step.prompt_context_builder is not None: + skill_content = render_prompt(skill_content, context, [], extra_context=runtime_context) parts = [p for p in [base_prompt, skill_content, rendered_step_prompt] if p] return "\n\n---\n\n".join(parts) diff --git a/src/iac_code/pipeline/engine/step_spec.py b/src/iac_code/pipeline/engine/step_spec.py index 1f74f7247..ff3279195 100644 --- a/src/iac_code/pipeline/engine/step_spec.py +++ b/src/iac_code/pipeline/engine/step_spec.py @@ -77,6 +77,8 @@ class StepSpec: on_enter: Callable[[PipelineContext], None] | None = None on_exit: Callable[[PipelineContext, dict], None] | None = None completion_enricher: Callable[..., dict[str, Any]] | None = None + #: Per-step skill/prompt replacements; never added to base prompt sections. + prompt_context_builder: Callable[[], dict[str, Any]] | None = None on_resource_observed: Callable[..., object] | None = None on_rollback_cleanup_required: Callable[..., object] | None = None #: Optional pre-check for structured waiting-input payloads. Returning a message keeps the step waiting. diff --git a/src/iac_code/pipeline/selling/references/coupons.md b/src/iac_code/pipeline/selling/references/coupons.md new file mode 100644 index 000000000..35d1b2b64 --- /dev/null +++ b/src/iac_code/pipeline/selling/references/coupons.md @@ -0,0 +1,77 @@ +# 阿里云优惠券查询与预算判断 + +## 使用原则 + +用户为部署方案询问优惠券、代金券或抵扣预算时,保留部署目标和规格,按需查询当前账号的券及适用条件。不要仅凭“学生券”“通用券”或面值,承诺可以用于目标配置,也不要因为用券问题中断部署规划。 + +可用、不适用或待确认只是用券结果,不代表用户取消部署;回答后以 `status: awaiting_selection` 保留候选等待选择,不提交 `rejected`。 + +券面值、剩余可抵扣金额与用户愿意补付的现金是三件事。只有用户明确要求不补现金时,才把可确认的抵扣额度作为可覆盖费用的上限;未明确时不要自行认定愿意补差价。无需为普通部署查询优惠券。 + +## 查询优惠券 + +使用当前会话的云凭证,只查询当前账号,不设置跨账号查询参数: + +```python +aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCoupon", + region_id="cn-hangzhou", params={"CurrentPage": 1, "PageSize": 20}) +``` + +返回 `Data` 为券数组,`CurrentPage`、`PageSize`、`TotalCount` 表示分页。需要继续寻找目标券时递增 `CurrentPage`;未读完列表时不能宣称账号没有该券。不要只按面值选择券;结合用户说明、券类型、状态和限制识别,多个候选无法区分时再澄清。不同券可能省略部分字段,缺失值保留未知,不当作零余额、无限制或已满足资格。 + +| 信息 | 返回字段与处理 | +|---|---| +| 券标识与类型 | `CouponId`、`CouponType`、`CouponTypeName`;不把不同类型的券都当作现金余额 | +| 金额 | `Amount` 为面值,`RemainAmount` 为余额,金额为字符串;计算时使用十进制金额并核对 `Currency` | +| 状态与期限 | `Status`、`StartTime`、`EndTime`;时间带时区,状态与当前时间均需核对,不能只看余额 | +| 消费场景 | `YhOrderTypes`,核对是否包含本次新购、续费或按量付费账单等场景 | +| 金额与购买时长限制 | `MoneyLimit`、`OrderTimeRule`;仅使用返回的明确规则,空值不等于无限制 | +| 首购等限制 | `FirstBuy`、`Remark`;有首购限制不代表当前账号已经满足资格 | +| 商品范围 | `ItemNames`、`SuitItemType`,必要时按下节查询编码;不能忽略白名单或黑名单语义 | + +适用场景和门槛按该张券的实际字段判断,不根据券类型统一推断“抵用券无门槛”或“满减券不能用于按量付费”。 + +## 查询适用商品 + +用上一接口返回的真实 `CouponId`,不要猜测或复用示例 ID: + +```python +aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCouponItemList", + region_id="cn-hangzhou", params={ + "CouponId": coupon_id, "CurrentPage": 1, "PageSize": 20 + }) +``` + +`Data` 为商品列表,每项包含 `Code` 和 `Name`,同样按 `TotalCount` 分页。结合该券的 `SuitItemType` 解释结果:`white` 表示指定商品适用,`black` 表示指定商品不适用,`all` 表示全产品通用范围;不能把黑名单中的商品当成可用商品。 + +商品编码和名称不是实例规格清单。即使包含 ECS,也不能仅凭这一项确认所有 GPU 规格、地域、付费方式或活动渠道均可用;缺少这些条件的证据时明确写“目标配置适用性待确认”。 + +## 判定结果 + +- **明确不可抵扣**:券已过期、用尽或作废,或者当前方案违反已返回的付费方式、商品排除、GPU 限制、金额门槛等规则。该券对本次费用的可抵扣金额为 0,不能再假设“也许仍能抵扣”。`Status` 为 `EXPIRED` 或 `EndTime` 已早于当前时间时,即使 `RemainAmount` 仍有数字,也不是有效余额;若只有这一张券,已知的本次费用需全部另付现金。 +- **条件性可抵扣**:券有效且余额、适用商品及所有已知限制均匹配时,按下面的费用范围计算;仍保留实际结算说明。 +- **待确认**:查询失败或缺少判断目标配置所必需的信息。不要把缺失信息当成已满足,也不要把已经明确不满足的条件重新写成待确认。适用性未确认时,可抵扣金额和需补现金也标为待确认,不能填入看似确定的金额后只在备注中说明未知;如展示假设计算,每个相关金额须明确标注“若适用”等前提。 + +## 结合方案解释费用 + +- 保持配置、地域、数量、计费方式和使用时长一致,再比较费用与可抵扣额度。按小时租用时使用有依据的按量单价,不用包月价除以固定小时数替代。 +- 分别说明资源粗估、已确认的券余额与适用条件,以及未确认的抵扣范围。优惠券余额不是折后资源单价,不直接从月费中减掉券面值来生成“每月优惠价”。 +- 只有代金券的适用范围及其限制已确认、且预计费用全部在可抵扣范围内时,才可给出条件性估算:预计需补现金为 `max(预计费用 - 可用余额, 0)`;仍注明实际抵扣以订单或账单为准。满减、折扣、门槛、部分费用不适用或多券叠加均不得套用该简式。 +- 条件已确认且可以计算时,现金栏填写带“预计”等限定的金额,实际结算说明单独列出。仅仅因为最终以账单结算为准,不把本来可以计算的预计金额改成“待确认”;缺少适用条件证据时才保留未知金额。 +- 仅明确按小时付费、未给出使用时长时,先展示小时粗估。只有抵扣条件足够明确时,才估算余额可覆盖的大致时长,并列出未覆盖的云盘、网络等费用;不能承诺券用完就自动停机或不再扣费。 + +## 查询失败与能力边界 + +接口成功但列表为空,只表示本次查询未返回券;权限不足、认证失败、分页未完成或服务异常都不能解释为没有券。保留当前部署方案,说明具体未知项,不编造抵扣结果。 + +用户明确要求重新查询或换用另一张券,而本轮查询失败时,不得把此前成功查询的券当成本轮依据;当前券余额、可抵扣金额和需补现金均保留待确认,不能沿用上一张券的门槛或排除规则计算。 + +用户已明确要求查询或重新查询时,按现有工具权限直接发起只读查询,不再用 `ask_user_question` 询问是否查询或确认查询方式。若查询后仍有多张券且无法确定目标券,再请用户选择;工具本身要求的权限确认仍走原权限机制。 + +所需只读权限分别为 `bss:DescribeCoupon` 和 `bss:DescribeCouponItemList`(资源 `*`);费用中心或子账号还可能因折扣查询权限受限,按接口实际错误提示处理,不自行改权限。查询 API 不会领取、使用、冻结或验证实际结算抵扣。 + +## 官方参考 + +- [DescribeCoupon:优惠券、余额与限制字段](https://help.aliyun.com/zh/user-center/developer-reference/api-bssopenapi-2023-09-30-describecoupon) +- [DescribeCouponItemList:商品编码与名称](https://help.aliyun.com/zh/user-center/developer-reference/api-bssopenapi-2023-09-30-describecouponitemlist) +- [优惠券规则与适用条件](https://help.aliyun.com/zh/user-center/how-to-use-coupons) diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md index da8f0ac80..22784ebee 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md @@ -277,7 +277,7 @@ ros_estimate_template_cost( | 文件 | 内容 | 何时查阅 | |------|------|----------| -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | 需要了解产品属性、规格选型、库存相关字段时 | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | 需要了解产品属性、规格选型、库存相关字段时 | | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | 修复 Parameters 定义(缺少 AssociationProperty、Label 等)时 | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | 修复资源定义、内置函数用法等模板结构问题时 | diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md index 09f51eca4..2ad002df2 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md @@ -177,5 +177,5 @@ conclusion_schema: | 文件 | 内容 | |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 原生模板最佳实践:RunCommand、嵌套栈、条件部署 | diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md index 5162088eb..4b0bde0be 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md @@ -113,5 +113,5 @@ conclusion_schema: | 文件 | 内容 | |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py b/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py index 66fa5bc09..b52e25f0a 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py @@ -23,6 +23,9 @@ on_resource_observed, on_rollback_cleanup_required, ) +from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import ( + normalize_solution_selection_costs, +) logger = logging.getLogger(__name__) @@ -133,6 +136,7 @@ def evaluate_deployment_gate(selected_plan: Any) -> str: def on_enter(ctx: PipelineContext) -> None: """Record the deployment gate verdict inside the existing ``selected_plan`` conclusion.""" + normalize_solution_selection_costs(ctx.snapshot().get("solution_selection")) selected_plan = ctx.get_conclusion("selected_plan") error = evaluate_deployment_gate(selected_plan) if not isinstance(selected_plan, dict): diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py b/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py index 4a64e3d82..ffab44d63 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py @@ -15,6 +15,9 @@ from iac_code.pipeline.engine.complete_step_tool import CompletionEnrichmentError from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.ui_contract import parse_deployment_confirmation +from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import ( + normalize_solution_selection_costs, +) from iac_code.tools.cloud.aliyun.ros_yaml import ros_yaml_load __all__ = [ @@ -68,6 +71,7 @@ def on_enter(ctx: PipelineContext) -> None: """Record candidate resolver state in Step 1 context for prompts and the deploy gate.""" selection = ctx.get_conclusion("solution_selection") + normalize_solution_selection_costs(selection) index, candidate, error = resolve_authoritative_candidate(selection) if not isinstance(selection, dict): return diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py b/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py index cff6337ce..f81869837 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py @@ -3,7 +3,9 @@ from __future__ import annotations import copy +import os import re +from pathlib import Path from typing import Any from iac_code.i18n import _ @@ -13,9 +15,33 @@ detail_record_matches, latest_candidate_detail_records, latest_candidate_outline_batch, + normalize_candidate_costs, + normalize_inventory_costs, ) -__all__ = ["enrich_completion_input"] +__all__ = ["build_prompt_context", "enrich_completion_input"] + + +def build_prompt_context() -> dict[str, Any]: + """Inject the short skill supplement only when ROS awareness is enabled.""" + enabled = os.environ.get("IAC_CODE_ROS_AGENT_AWARENESS", "").strip().casefold() in {"1", "true", "yes", "on"} + if not enabled: + return {"ros_agent_awareness": ""} + + from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredentials + + # The credential loader prefers the current session's request-local override. + credential = AliyunCredentials.load() + region = (credential.region_id if credential is not None else "").strip() + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", region): + region = DEFAULT_REGION + destination = f"[AI Assistant](https://ros.console.aliyun.com/{region}/chat)" + supplement = ( + Path(__file__).resolve().parents[1] + / "skills/iac-aliyun-solution-first/ros-agent-awareness.md" + ).read_text(encoding="utf-8").strip() + return {"ros_agent_awareness": "\n\n" + supplement.replace("{ai_assistant_destination}", destination)} + # 说服力字段与各自的最少条目数。缺失或空数组时方案卡只剩一句概述,用户无从判断为什么该选它, # 因此这里和 completion_input_schema 一样把它们当硬要求,而不是可选补充。 @@ -26,15 +52,16 @@ ("cons", 1), ) -_COMPOSED_MONTHLY_PRICE_RE = re.compile( +_COST_PERIOD_RE = r"(?:小时|天|日|周|月|年|hours?|days?|weeks?|months?|years?)" +_COMPOSED_PRICE_RE = re.compile( r"^\s*(?:约\s*)?(?:" r"[¥¥]\s*[\d,.]+(?:\s*(?:[-~~—–]|至|到)\s*(?:[¥¥]\s*)?[\d,.]+)?" - r"\s*(?:元|CNY|RMB)?\s*(?:(?:[//]\s*)?月|每月)?" + rf"\s*(?:元|CNY|RMB)?\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?" r"|[\d,.]+(?:\s*(?:[-~~—–]|至|到)\s*(?:[¥¥]\s*)?[\d,.]+)?" - r"\s*(?:元\s*(?:(?:[//]\s*)?月)?|(?:[//]\s*)月|每月)" + rf"\s*(?:元\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?|[//]\s*{_COST_PERIOD_RE}|每{_COST_PERIOD_RE})" r"|(?:CNY|RMB)\s*[\d,.]+(?:\s*(?:[-~~—–]|至|到)\s*(?:CNY|RMB)?\s*[\d,.]+)?" - r"\s*(?:(?:[//]\s*)?月|每月)?" - r"|(?:免费|零费用|无费用)\s*(?:(?:[//]\s*)?月|每月)?" + rf"\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?" + rf"|(?:免费|零费用|无费用)\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?" r")\s*$", re.IGNORECASE, ) @@ -222,7 +249,7 @@ def _candidate_from_outline_and_detail( outline: dict[str, str], detail: dict[str, Any], ) -> dict[str, Any]: - inventory = copy.deepcopy(detail.get("resource_inventory")) + inventory = normalize_inventory_costs(detail.get("resource_inventory")) inventory = inventory if isinstance(inventory, list) else [] return { "name": outline["candidate_name"], @@ -233,7 +260,7 @@ def _candidate_from_outline_and_detail( "resource_inventory": inventory, "rough_cost": { "currency": "CNY", - "monthly_range": outline["total_monthly_cost"], + "cost_range": outline["total_cost"], "items": _cost_items_from_inventory(inventory), "assumptions": copy.deepcopy(detail.get("cost_assumptions") or []), "exclusions": copy.deepcopy(detail.get("cost_exclusions") or []), @@ -256,7 +283,7 @@ def _cost_items_from_inventory(inventory: list[Any]) -> list[dict[str, str]]: { "name": str(raw.get("product") or raw.get("resource_id") or "").strip(), "spec": spec, - "monthly_cost": str(raw.get("rough_monthly_cost") or "").strip(), + "cost": str(raw.get("rough_cost") or "").strip(), } ) return items @@ -278,7 +305,7 @@ def _normalize_candidate( if not isinstance(summary, str) or not summary.strip(): raise CompletionEnrichmentError(_("candidates[{index}].summary must be non-empty").format(index=index)) - normalized = copy.deepcopy(candidate) + normalized = normalize_candidate_costs(candidate) normalized["candidate_id"] = f"candidate-{index}" normalized["name"] = name.strip() normalized["summary"] = summary.strip() @@ -396,7 +423,7 @@ def _topology_text(candidate: dict[str, Any]) -> str: def _candidate_option_summary(candidate: dict[str, Any]) -> str: - """Compose 「概述;月度价格;首要代价」, idempotently. + """Compose 「概述;当前周期价格;首要代价」, idempotently. 重新规划时模型在自己的上下文里看到的是上一轮拼好的选项文案,会把它原样当成候选概述 交回来,于是价格与代价在方案卡里出现两遍。价格前的分隔符是上一轮拼接留下的标记, @@ -404,9 +431,9 @@ def _candidate_option_summary(candidate: dict[str, Any]) -> str: """ summary = str(candidate.get("summary") or "") rough_cost = candidate.get("rough_cost") - monthly_range = rough_cost.get("monthly_range") if isinstance(rough_cost, dict) else None + cost_range = rough_cost.get("cost_range") if isinstance(rough_cost, dict) else None notes = candidate.get("cons") - price = str(monthly_range or "") + price = str(cost_range or "") tradeoff = str(notes[0]) if isinstance(notes, list) and notes else "" summary = _strip_composed_option_tail(summary, current_price=price) summary = summary.strip() @@ -427,6 +454,6 @@ def _strip_composed_option_tail(summary: str, *, current_price: str) -> str: # A projected option always has content before the price and a trade-off after it. # Requiring a complete price-shaped segment avoids trimming ordinary prose that uses semicolons. for index in range(1, len(segments) - 1): - if _COMPOSED_MONTHLY_PRICE_RE.fullmatch(segments[index]): + if _COMPOSED_PRICE_RE.fullmatch(segments[index]): return ";".join(segments[:index]) return summary diff --git a/src/iac_code/pipeline/selling_solution_first/pipeline.yaml b/src/iac_code/pipeline/selling_solution_first/pipeline.yaml index 654ef8578..6b16ff34f 100644 --- a/src/iac_code/pipeline/selling_solution_first/pipeline.yaml +++ b/src/iac_code/pipeline/selling_solution_first/pipeline.yaml @@ -299,28 +299,28 @@ steps: billing_method: type: string description: 用于粗估的计费方式,例如按量付费。 - rough_monthly_cost: + rough_cost: type: string - description: 该清单项的月度粗估区间或免费说明。 + description: 该清单项的粗估区间及周期,默认按月,用户指定单位或时长时与其一致。 lifecycle: type: string enum: [create, use_existing, reference, forbid] description: 与 resource_intents 一致的资源生命周期。 rough_cost: type: object - description: Step 1 的架构级月度粗估,不是 ROS 精确询价结果。 - required: [currency, monthly_range, items, assumptions, exclusions, confidence] + description: 架构级费用粗估,默认按月,用户指定单位或时长时与其一致;不是 ROS 精确询价结果。 + required: [currency, cost_range, items, assumptions, exclusions, confidence] properties: currency: type: string enum: [CNY] description: 粗估费用币种,固定为 CNY。 - monthly_range: + cost_range: type: string - description: 面向用户的月度总费用区间,例如 ¥500~¥800/月。 + description: 面向用户的总费用区间及实际周期,例如 ¥500~¥800/月或 ¥3~¥5/小时。 items: type: array - description: 构成月度粗估的主要计费项。 + description: 构成当前周期粗估的主要计费项。 items: type: object description: 一项产品或资源的粗估费用。 @@ -331,9 +331,9 @@ steps: spec: type: string description: 采用的粗估规格与数量。 - monthly_cost: + cost: type: string - description: 该项月度粗估区间或免费说明。 + description: 该项粗估区间及实际周期,须与总费用使用同一单位和时长。 assumptions: type: array description: 粗估成立所依据的地域、用量、规格和计费假设。 diff --git a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md index dbd0b2fed..2d5232184 100644 --- a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md +++ b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md @@ -25,13 +25,14 @@ ### 选择恢复或回滚重规划 -上方状态为 `awaiting_selection` 时,用户已经看过候选。本轮消息只能按以下三类处理: +上方状态为 `awaiting_selection` 时,用户已经看过候选。本轮消息按以下四类处理: 1. **选择候选**:按候选坐标、唯一名称或自然语言偏好映射到已保存的候选,只提交下面的选择增量;不要重复 candidates、intent、options 或 selected_candidate。 2. **修改当前架构**:结合用户新增要求重新规划,用一次新的 `show_architecture_plan` 提交修改后的完整摘要 批次,再逐个细化;不得提交增量 patch,也不得把架构修改误报为已选择。 3. **替换部署目标**:本轮最新输入成为新的权威需求,丢弃旧 `intent`、候选和产品组合,重新执行技能的 意图、摘要批次和逐候选细化流程。新旧部署需求不得合并。 +4. **咨询当前方案**:配置、费用或用券适用性等追问先按事实可见答复,按需查询,并使用用户要求的表格等格式;不把咨询当成选择或取消部署。每次都先输出本轮可见答复,再调用 `complete_step`;只提交等待选择结论不能代替回答,重复提问或重新查询也不能省略答复。没有修改要求时保留候选并提交 `awaiting_selection`,不重新规划或进入实现。 用户只要求“重新选择方案”且没有增加架构要求或替换部署目标时,不要重新生成、展示或重复提交候选;提交 `{"conclusion":{"status":"awaiting_selection"}}`,Python 会从已保存的 `solution_selection` 恢复原候选并重新打开选择界面。 @@ -43,7 +44,6 @@ 本 Step 只选择架构,不接收部署参数。输入中的 `parameter_overrides`、`deployment_parameters` 或 `parameters` 不写入结论,部署参数统一交给下一步处理。 选择分支的 `complete_step` 形状: - `{"conclusion":{"status":"selected","selected_candidate_index":0}}` 首次规划或用户明确修改架构/替换部署目标时须提交包含 `status` 和 `intent` 的 @@ -57,3 +57,4 @@ candidate_id、output_path 和同序 options。 - `complete_step` 参数必须是 `{"conclusion": {...}}`,不得把结论字段放到工具参数顶层。 - 本 Step 不生成或写入模板、不做 ROS 精确询价、不执行云写操作。只读查询、记忆读取和展示的边界遵循技能与当前工具权限。 +{ros_agent_awareness} diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md index eb7499a2f..6aa9a1b1b 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md @@ -178,7 +178,7 @@ conclusion_schema: | 文件 | 内容 | |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 原生模板最佳实践:RunCommand、嵌套栈、条件部署 | diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md index eeeff3602..4268a3562 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md @@ -212,7 +212,7 @@ Preview 明确表明新建资源受配额、容量或账号限制时,先用 `a | 文件 | 内容 | 何时查阅 | |------|------|----------| -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md) | 需要了解产品属性、规格选型、库存相关字段时 | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | 需要了解产品属性、规格选型、库存相关字段时 | | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | 生成或修复 Parameters 定义时 | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | 生成或修复资源定义、内置函数用法等模板结构问题时 | | [references/template-parameter-recommendation.md](references/template-parameter-recommendation.md) | 参数推荐与回溯规则、PreviewStack 参数集形成方法 | 求解库存/已有资源参数并形成预览参数集时 | diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md index 22329822b..6a6f2aadb 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md @@ -11,7 +11,7 @@ user_invocable: false 本流程只支持阿里云。用户明确要求 AWS、Azure、GCP、腾讯云、华为云等非阿里云平台时,不要输出对应平台资源,不要把它作为支持的基础设施需求继续推进;必须先澄清是否改为阿里云目标,或将其作为不支持/非阿里云需求结束。 -本步骤不生成模板、不询价、不执行任何云写操作。 +本步骤不生成模板、不做完整询价验证、不执行任何云写操作。 ## 第一部分:意图分类 @@ -122,6 +122,8 @@ user_invocable: false ### 硬约束提取规则 +CPU、内存、磁盘、带宽等规格直接影响费用,必须准确区分用户的最低要求、推荐配置与固定要求,不得擅自升降规格或把推荐值提升为硬约束。同一规格同时有最低值和推荐值时,最低值作为约束下限,推荐值仅作选型偏好。 + 每条硬约束是一个对象,字段为 `id`、`target`、`property`、`operator`、`value`、可选 `unit`、`verification_mode`、`source`、`source_text`: - `id`:当前请求内稳定且唯一的约束标识;用户修改同一约束时保持 ID,内容更新为最新要求。 @@ -136,7 +138,7 @@ user_invocable: false 提取规则: -- "2 核 4 GiB"可提取为同一目标的 `vcpu eq 2 count` 与 `memory eq 4 GiB` 两条约束;它们需要把实际产品规格映射到具体部署参数,因此使用 `verification_mode: tool`。这里只负责忠实表达,不选择具体实例规格或 API。 +- 用户明确固定为"2 核 4 GiB"时,提取 `vcpu eq 2 count` 与 `memory eq 4 GiB`;若同时说明"最低 2 GiB,推荐 4 GiB",内存只提取 `memory gte 2 GiB`,不得再加 `memory eq/gte 4 GiB`。规格需要映射到具体部署参数,使用 `verification_mode: tool`;这里只忠实表达,不选择具体实例规格或 API。 - 将用户口语单位规范化后写入结构化字段:CPU 的"核/核心/vCPU"统一为 `count`,内存语境中的 `g/G` 统一为 `GiB`、`m/M` 统一为 `MiB`;保留用户原始表达在 `source_text`,不要把内存单位误解为带宽单位。 - "至少 100 GiB""带宽不超过 20 Mbps""只能用 8.0""不要公网 IP"分别使用 `gte`、`lte`、`in/eq`、`eq false` 等通用表达。 - 实际值能从模板属性或最终部署参数直接定位时使用 `verification_mode: direct`;依赖云产品元数据、SKU 映射、库存或已有资源状态时使用 `verification_mode: tool`。 @@ -161,6 +163,10 @@ user_invocable: false ## 第二部分:详细架构规划 +规划、粗估或调用某个阿里云产品的 API 前,必须先用 `read_file` 读取 [references/cloud-products/](references/cloud-products/) 下该产品的对应文档,了解选型策略、配置限制和计费方式。同一上下文已完整读取时直接复用;只加载本次涉及产品的文档。 + +产品与文件映射:云服务器 ECS → `ecs.md`;轻量应用服务器 SWAS → `swas.md`;云数据库 RDS → `rds.md`;云数据库 Redis → `redis.md`;负载均衡 SLB/CLB/ALB/NLB → `slb.md`;专有网络 VPC → `vpc.md`;对象存储 OSS → `oss.md`;全球加速 GA → `ga.md`。 + ### 核心原则:按需设计,不过度发挥 方案数量取决于需求复杂度,而非固定出 2-3 个凑数: @@ -242,7 +248,7 @@ user_invocable: false "quantity": 2, "recommended_spec": "2 vCPU / 4 GiB,最终规格以库存和询价为准", "billing_method": "包年包月或按量付费", - "rough_monthly_cost": "¥400~¥700/月", + "rough_cost": "¥400~¥700/月", "lifecycle": "create" } ``` @@ -293,8 +299,8 @@ user_invocable: false ```json { "currency": "CNY", - "monthly_range": "¥1800~¥2600/月", - "items": [{"name": "ECS", "spec": "2 vCPU / 4 GiB × 2", "monthly_cost": "¥400~¥700/月"}], + "cost_range": "¥1800~¥2600/月", + "items": [{"name": "ECS", "spec": "2 vCPU / 4 GiB × 2", "cost": "¥400~¥700/月"}], "assumptions": ["地域 cn-hangzhou", "按量付费", "ECS 2 台"], "exclusions": ["公网流量费", "日志写入量", "跨地域流量"], "confidence": "low" @@ -302,12 +308,18 @@ user_invocable: false ``` - 只给区间,不要求精确到个位;费用估算基于阿里云公开定价的合理范围。 +- 默认按月估算;用户明确指定小时、天、周或年时,总价、资源明细和预算比较均使用该单位。另给出使用时长时,同时说明该时长的预计总费用;仅指定计费单位时,不擅自假定使用时长。 +- 用户修改周期或时长时,保留未改变的规格与部署目标,更新费用及其假设。不能用包月价直接推导按量单价,或拿不同周期的金额判断超预算。 +- 金额字符串必须明确标出实际估算周期,不能把小时、天或年费用显示为月费。 - 必须在 `assumptions` 中说明地域、计费方式、数量和规格假设。 +- 套餐型产品可复用上下文中的目标地域套餐参考信息,信息不足时按需查询;粗估不额外验证活动资格或逐套餐询价。说明价格来源和购买时长,不凭假设的促销价使方案满足预算;用户提供的活动价注明适用条件尚未确认。 - 不包含的费用必须显式列入 `exclusions`,例如公网流量、短信、日志写入量、跨地域流量。 - 无法形成可信估计时,使用较宽区间并标记 `confidence: low`,不得伪造精确金额。 - 有用户预算硬约束时,候选区间上限原则上不得超过预算;确实无法满足时先澄清或明确标记冲突,不得静默放宽预算。 - 本阶段**不调用** `ros_estimate_template_cost`,也**不输出** `OriginalAmount`/`TradeAmount`。精确询价由实现步骤完成。 +部署需求涉及优惠券、代金券或用券预算时,先用 `read_file` 按需读取 [优惠券指南](references/coupons.md),再识别与查询券的适用条件;同一上下文已读取时直接复用。此类问题留在部署流程内,不因提到优惠券而分流或拒绝部署。 + ### 方案说服力 方案卡直接展示 `decision_notes`,它决定用户能不能判断「为什么该选这个方案」。四个字段必填,且不得为空数组: @@ -358,7 +370,7 @@ user_invocable: false 展示分两阶段执行: 1. 先调用一次 `show_architecture_plan(candidates=[...])`,提交本轮**全部**轻量候选摘要。每项只包含 - `candidate_name`、`summary`、`total_monthly_cost` 和 `key_tradeoff`。数组顺序定义 0 基候选坐标, + `candidate_name`、`summary`、`total_cost` 和 `key_tradeoff`。数组顺序定义 0 基候选坐标, 不得在此工具中提交 `nodes`、`edges`、资源清单或详细价格项。用户要求增减方案时重新提交修改后的 完整摘要数组,不提交增量 patch。 2. 摘要批次成功后,按 0 基下标逐个调用 `show_candidate_detail`。每个模型轮次只细化一个候选,参数中的 @@ -377,12 +389,13 @@ user_invocable: false 没有成功摘要批次时不得调用 `show_candidate_detail`;详情数量、下标或名称不完整时不得调用 `complete_step`。某个详情失败时只修正并重试该候选。 -候选的 `summary`、`rough_cost.monthly_range` 和 `decision_notes` 应足够支持 Python 生成紧凑 options;不要只给泛化名称。 +候选的 `summary`、`rough_cost.cost_range` 和 `decision_notes` 应足够支持 Python 生成紧凑 options;不要只给泛化名称。 ### 处理选择 用户选择后本步骤会带着已保存的候选上下文恢复执行: +- "为什么不能选……""这个能用吗"等适用性或价格追问不是选案确认。先回答问题;没有明确选择或修改要求时,不提交 `selected`、不进入实现,也不重新规划。 - 结构化选择消息优先使用候选坐标 `selected_candidate_index` / `selected_evaluated_candidate_index`(两者一致,都是 0 基下标)。 - 用户提供候选名称时按 `name` 匹配;名称重复时必须用下标消歧。 - 本步骤只确定架构方案,不接收部署参数覆盖。结构化消息中的 `parameter_overrides`、`deployment_parameters` 或 `parameters` 不写入结论;模板生成后的部署参数统一由下一步处理。 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json index c7c16568d..cdb5f87de 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json @@ -73,7 +73,7 @@ {"name": "single_candidate", "check": "candidates 数组长度为 1"}, {"name": "no_extra_resources", "check": "products 中只包含 VPC,不额外创建 VSwitch、SecurityGroup、ECS、RDS 或 SLB"}, {"name": "still_awaits_selection", "check": "status 为 awaiting_selection,即使只有一个候选也要用户明确选择"}, - {"name": "shows_outline_batch", "check": "先调用一次 show_architecture_plan,candidates 仅含 candidate_name、summary、total_monthly_cost、key_tradeoff"}, + {"name": "shows_outline_batch", "check": "先调用一次 show_architecture_plan,candidates 仅含 candidate_name、summary、total_cost、key_tradeoff"}, {"name": "shows_candidate_detail", "check": "再调用一次 show_candidate_detail,提交下标 0 的 topology_graph、resource_inventory、费用假设和 decision_notes"}, {"name": "compact_completion", "check": "complete_step 只提交 status 和 intent,不提交完整 candidates"} ] @@ -90,7 +90,7 @@ {"name": "meaningful_diff", "check": "方案之间的 products 组合或 topology 有实质差异,不是仅规格不同"}, {"name": "has_resource_inventory", "check": "每个候选的 resource_inventory 非空,且每条含 product、purpose、quantity、lifecycle"}, {"name": "has_topology_graph", "check": "每个候选的 topology_graph.nodes 非空,且 edges 的 source/target 都引用已定义的节点 id"}, - {"name": "has_rough_cost", "check": "每个候选的 rough_cost 含 monthly_range、assumptions 和 exclusions"}, + {"name": "has_rough_cost", "check": "每个候选的 rough_cost 含 cost_range、assumptions 和 exclusions"}, {"name": "no_exact_pricing", "check": "不调用 ros_estimate_template_cost,不输出 OriginalAmount/TradeAmount"} ] }, @@ -100,7 +100,7 @@ "prompt": "部署一个API服务,月预算500元以内", "expected_behavior": "在预算约束下给出 1-2 个候选,rough_cost 区间上限不超过预算,assumptions 说明地域和计费方式", "assertions": [ - {"name": "within_budget", "check": "所有候选 rough_cost.monthly_range 上限不超过 500 CNY"}, + {"name": "within_budget", "check": "所有候选 rough_cost.cost_range 上限不超过 500 CNY"}, {"name": "no_overengineering", "check": "不包含 SLB 集群、多可用区等超出预算的高可用配置"}, {"name": "documents_assumptions", "check": "rough_cost.assumptions 说明地域、计费方式和数量假设"}, {"name": "budget_recorded_as_constraint", "check": "预算写入 intent.budget_constraint,并作为硬约束快照传入候选"} diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references new file mode 120000 index 000000000..9fc803476 --- /dev/null +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references @@ -0,0 +1 @@ +../../../selling/references \ No newline at end of file diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md new file mode 100644 index 000000000..24b6fb588 --- /dev/null +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md @@ -0,0 +1,4 @@ +ROS console override: +1) Deployment coupon/budget: read coupons.md, query/answer visibly; keep `awaiting_selection`; NEVER link/reject. +2) ELSE standalone billing, renewals/promos, prices/availability or traffic/selection promos: OUTPUT {ai_assistant_destination} as visible text, NOT only tool input; only `complete_step` with `status: rejected`, full URL in `rejection_reason`; override clarification/reselection. +Keep deployment architecture/cost tradeoffs here; do not promise discounts or session transfer. diff --git a/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py b/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py index 8058e0ee7..c3b49f415 100644 --- a/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py +++ b/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py @@ -7,6 +7,7 @@ from __future__ import annotations +import copy from typing import Any @@ -71,8 +72,9 @@ def normalize_outline_candidates(value: Any) -> list[dict[str, str]] | None: if not isinstance(item, dict): return None candidate: dict[str, str] = {} - for field in ("candidate_name", "summary", "total_monthly_cost", "key_tradeoff"): - raw = item.get(field) + for field in ("candidate_name", "summary", "total_cost", "key_tradeoff"): + # Old persisted outline records remain readable, but schemas expose only total_cost. + raw = item.get(field, item.get("total_monthly_cost") if field == "total_cost" else None) if not isinstance(raw, str) or not raw.strip(): return None candidate[field] = raw.strip() @@ -83,6 +85,52 @@ def normalize_outline_candidates(value: Any) -> list[dict[str, str]] | None: return normalized +def _rename_cost_field(value: dict[str, Any], old: str, new: str) -> None: + if old in value: + if new not in value: + value[new] = value[old] + del value[old] + + +def normalize_inventory_costs(value: Any) -> Any: + """Copy legacy resource costs into the period-neutral planning contract.""" + inventory = copy.deepcopy(value) + if isinstance(inventory, list): + for resource in inventory: + if isinstance(resource, dict): + _rename_cost_field(resource, "rough_monthly_cost", "rough_cost") + return inventory + + +def normalize_candidate_costs(value: Any) -> Any: + """Migrate only known planning fields, preserving values and canonical-key precedence.""" + candidate = copy.deepcopy(value) + if not isinstance(candidate, dict): + return candidate + if "resource_inventory" in candidate: + candidate["resource_inventory"] = normalize_inventory_costs(candidate["resource_inventory"]) + cost = candidate.get("rough_cost") + if isinstance(cost, dict): + _rename_cost_field(cost, "monthly_range", "cost_range") + items = cost.get("items") + if isinstance(items, list): + for item in items: + if isinstance(item, dict): + _rename_cost_field(item, "monthly_cost", "cost") + return candidate + + +def normalize_solution_selection_costs(value: Any) -> None: + """Normalize restored candidates in place without changing context versions or stale flags.""" + if not isinstance(value, dict): + return + candidates = value.get("candidates") + if isinstance(candidates, list): + value["candidates"] = [normalize_candidate_costs(candidate) for candidate in candidates] + if "selected_candidate" in value: + value["selected_candidate"] = normalize_candidate_costs(value["selected_candidate"]) + + def latest_candidate_detail_records( records: list[dict[str, Any]], batch: CandidateOutlineBatch, @@ -147,4 +195,7 @@ def _record_sequence(record: dict[str, Any], position: int) -> int: "latest_candidate_detail_records", "latest_candidate_outline_batch", "normalize_outline_candidates", + "normalize_inventory_costs", + "normalize_candidate_costs", + "normalize_solution_selection_costs", ] diff --git a/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py b/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py index e4468d60e..299f24087 100644 --- a/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py +++ b/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py @@ -49,7 +49,7 @@ def name(self) -> str: def description(self) -> str: return _( "Display one complete batch of lightweight candidate outlines before rich details are generated. " - "Submit every current candidate in order with its name, summary, monthly estimate and key trade-off. " + "Submit every current candidate in order with its name, summary, cost estimate and key trade-off. " "Do not include topology nodes, resource inventory or detailed cost items." ) @@ -79,10 +79,13 @@ def input_schema(self) -> dict[str, Any]: "minLength": 1, "description": _("Short product combination and architecture summary"), }, - "total_monthly_cost": { + "total_cost": { "type": "string", "minLength": 1, - "description": _("Rough monthly range, such as ¥230~¥380/month"), + "description": _( + "Rough cost range including its period: monthly by default, " + "or the user's requested unit and duration." + ), }, "key_tradeoff": { "type": "string", @@ -90,7 +93,7 @@ def input_schema(self) -> dict[str, Any]: "description": _("The most important cost, availability or complexity trade-off"), }, }, - "required": ["candidate_name", "summary", "total_monthly_cost", "key_tradeoff"], + "required": ["candidate_name", "summary", "total_cost", "key_tradeoff"], }, }, }, @@ -110,7 +113,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> return ToolResult.error( _( "candidates must be a non-empty array of unique outlines with candidate_name, summary, " - "total_monthly_cost and key_tradeoff" + "total_cost and key_tradeoff" ) ) records = self._completion_guard_state.get("tool_result_records") @@ -134,7 +137,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> candidate_name=candidate["candidate_name"], summary=candidate["summary"], cost_items=[], - total_monthly_cost=candidate["total_monthly_cost"], + total_monthly_cost=candidate["total_cost"], candidate_index=candidate_index, candidate_set_id=candidate_set_id, detail_stage="outline", diff --git a/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py b/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py index 8607e1269..a1c99e743 100644 --- a/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py +++ b/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py @@ -10,6 +10,7 @@ from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import ( first_missing_candidate_detail_index, latest_candidate_outline_batch, + normalize_inventory_costs, ) from iac_code.pipeline.selling_solution_first.tools.show_architecture_plan_tool import ( render_architecture_graph, @@ -33,7 +34,7 @@ def description(self) -> str: return _( "Display the rich detail for exactly one candidate from the latest show_architecture_plan batch. " "Call once per model turn in candidate index order. Include resource lifecycle intent, topology graph, " - "resource inventory, cost assumptions and decision notes; do not repeat summary or monthly total." + "resource inventory, cost assumptions and decision notes; do not repeat summary or cost total." ) @property @@ -126,7 +127,12 @@ def input_schema(self) -> dict[str, Any]: "quantity": {"type": "integer", "minimum": 1}, "recommended_spec": {"type": "string"}, "billing_method": {"type": "string"}, - "rough_monthly_cost": {"type": "string"}, + "rough_cost": { + "type": "string", + "description": ( + "Rough cost including its period, matching the outline's unit and duration." + ), + }, "lifecycle": { "type": "string", "enum": ["create", "use_existing", "reference", "forbid"], @@ -236,7 +242,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> candidate_name=expected_name, summary=outline["summary"], cost_items=cost_items, - total_monthly_cost=outline["total_monthly_cost"], + total_monthly_cost=outline["total_cost"], candidate_index=expected_index, candidate_set_id=batch.candidate_set_id, detail_stage="detail", @@ -283,7 +289,8 @@ def _cost_items_from_inventory(value: Any) -> list[dict[str, str]]: if not isinstance(value, list): return [] items: list[dict[str, str]] = [] - for raw in value: + # CandidateDetailEvent is the compatibility boundary for existing UI clients. + for raw in normalize_inventory_costs(value): if not isinstance(raw, dict): continue product = str(raw.get("product") or raw.get("resource_id") or "").strip() @@ -295,7 +302,7 @@ def _cost_items_from_inventory(value: Any) -> list[dict[str, str]]: { "name": product, "spec": spec, - "monthly_cost": str(raw.get("rough_monthly_cost") or "").strip(), + "monthly_cost": str(raw.get("rough_cost") or "").strip(), } ) return items diff --git a/src/iac_code/skills/bundled/iac_aliyun/SKILL.md b/src/iac_code/skills/bundled/iac_aliyun/SKILL.md index 3da1b458e..d68ae4ad1 100644 --- a/src/iac_code/skills/bundled/iac_aliyun/SKILL.md +++ b/src/iac_code/skills/bundled/iac_aliyun/SKILL.md @@ -173,7 +173,7 @@ auto_trigger: |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组(ROS/Terraform 共用) | | [references/template-parameter-recommendation.md](references/template-parameter-recommendation.md) | 已有 ROS 可部署模板的部署前参数推荐流程 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 原生模板最佳实践:RunCommand、嵌套栈、条件部署 | | [references/terraform-template.md](references/terraform-template.md) | Terraform 最佳实践:文件组织、变量、Data Source、ROS 集成 | diff --git a/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md index c27f625c5..d511514f1 100644 --- a/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +++ b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md @@ -14,6 +14,10 @@ - 性价比优先配置:u1-/u2a-/u2i- - 性能优先配置:g9i/c9i/r9i +规格族特点: +- **经济型 e**:低成本共享型实例,适合轻量应用和开发测试,CPU 性能可能因资源竞争而波动。 +- **突发性能型 t 系列**:适合低平均负载、偶发高负载,通过 CPU 积分提供突发性能,需关注基准性能及积分费用。 + ### vCPU / 内存确定原则 - 用户明确指定 vCPU 或内存时,该数值是硬约束,直接覆盖本节的场景化推荐与档位推导。必须用 `DescribeInstanceTypes` 返回的实际规格验证,不能根据实例规格名猜测,也不能静默升级或降级。 diff --git a/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md new file mode 100644 index 000000000..6294ac7ab --- /dev/null +++ b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md @@ -0,0 +1,84 @@ +# 阿里云轻量应用服务器(SWAS)选型指南 + +## 适用场景 + +- 适合个人网站、开发测试、小型 Web 和单机应用,优先考虑标准套餐及应用镜像,减少环境搭建工作。 +- 需要 GPU、复杂网络、弹性伸缩或更丰富实例规格时,选择 ECS。 +- SWAS 是独立产品,底层基于 ECS 的计算、存储和网络资源;API 产品标识为 `SWAS-OPEN`,套餐、镜像和防火墙使用 SWAS 自己的标识与接口。 + +## 套餐选择 + +根据目标地域下的实际套餐,选择满足应用要求的配置: + +| 维度 | 选择依据 | +|------|----------| +| 计算 | `Core`(vCPU)、`Memory`(GiB):满足应用及同机运行服务的 CPU、内存需求 | +| 存储 | `DiskSize`(GiB)、`DiskType`:系统、应用与数据所需容量及磁盘类型 | +| 网络 | `Bandwidth`(Mbps)、`Flow`(GB/月):峰值带宽与每月流量配额,结合访问量选择 | +| 操作系统 | `SupportPlatform`:套餐支持的 Linux / Windows 平台 | +| 价格 | `OriginPrice`、`Currency`:套餐包月参考价与币种 | + +以查询结果关联 `PlanId` 与规格,不根据套餐 ID 猜测配置。不同地域的套餐及镜像可能不同,不直接复用其他地域的选择。 + +`OriginPrice` 返回字符串金额,计算费用时按数值处理;`SupportPlatform` 是 JSON 数组形式的字符串(如 `["Linux","Windows"]`),需解析后匹配平台。`Flow` 可能缺失,不将缺失值直接解释为零流量或无限流量;结合当前套餐说明判断,信息不足时保留未知。 + +## 镜像选择 + +- 标准应用或容器环境:优先匹配应用镜像,例如 WordPress、Docker;核对镜像描述中的系统与组件版本是否适配应用。 +- 自定义技术栈:选择兼容的系统镜像,自行安装运行环境。 +- 复制已有环境:可使用轻量应用服务器的自定义镜像。 +- 使用 `ListImages` 返回的 SWAS `ImageId`,不要直接使用 ECS 镜像 ID,也不要把教程中的旧版本或示例 ID 当成当前可用值。 +- 镜像列表可能包含旧版本,优先选择仍受维护且满足应用依赖的版本,不按返回顺序直接取第一项。 + +## 存储与网络配置 + +- 套餐已包含系统盘和公网访问能力,不要为同一能力额外配置 ECS 系统盘或 EIP。 +- 应用数据超过系统盘容量时,可增加一块单独计费的数据盘;每台实例最多挂载一块,数据盘不能合并到系统分区。需要扩大系统盘时升级套餐。 +- 实例使用 SWAS 防火墙控制入流量。按最小授权原则配置业务端口与来源地址,不套用 ECS 安全组。 +- 流量政策按套餐区分:固定流量配额套餐可能产生超额流量费,无固定流量套餐按其计费说明处理;不要仅凭峰值带宽承诺无限流量。 + +## 计费与价格 + +- 套餐和数据盘按包年包月购买;适用的超额流量按量计费。 +- `ListPlans.OriginPrice` 是套餐的**包月价格**。参考总价可按「包月价格 × 购买月数 × 台数」折算,但该值不包含年付折扣、活动优惠或额外数据盘等费用。 +- 包月价、年付总价和活动价分别说明,比较时保持地域、套餐、购买时长、数量及费用范围一致。 +- 首购、学生、新人或续费等活动价格受活动条件约束,不凭规格相同就认定用户可享受同一优惠;实际费用以当前购买或询价结果为准。 + +## 套餐与镜像查询 + +### 查询目标地域的套餐 + +```python +aliyun_api(product="SWAS-OPEN", action="ListPlans", region_id="<目标地域>") +``` + +根据套餐字段筛选配置,保留对应的 `PlanId`、价格及币种。套餐列表描述规格和参考价格,不代表已经完成库存或创建可行性检查。 + +### 查询目标地域的镜像 + +```python +aliyun_api(product="SWAS-OPEN", action="ListImages", params={ + "ImageType": "system" +}, region_id="<目标地域>") +``` + +`ImageType` 可选 `system`(系统镜像)、`app`(应用镜像)或 `custom`(自定义镜像);根据镜像的 `Platform`、名称和描述匹配套餐平台与应用依赖。 + +## 最佳实践 + +- 应用镜像能够满足需求时,优先复用预装环境;自定义部署应匹配操作系统与依赖版本。 +- 网站或 API 仅开放必要业务入口;管理端口限制来源,数据库等内部服务避免直接向公网开放。 +- 重要数据保留备份;重置系统前备份系统盘数据,重置会清除原有系统盘内容。 +- 持续监控资源使用情况,容量不足时考虑升级套餐。套餐仅支持升配、不支持降配,也不支持单独升级 CPU、内存、系统盘或带宽;需要更复杂资源配置时评估迁移到 ECS。 + +## 官方参考 + +- [产品概述](https://help.aliyun.com/zh/simple-application-server/product-overview/what-is-simple-application-server) +- [与 ECS 对比](https://help.aliyun.com/zh/simple-application-server/product-overview/comparison-between-simple-application-server-and-ecs) +- [ListPlans:套餐信息与包月价格字段](https://help.aliyun.com/zh/simple-application-server/developer-reference/api-swas-open-2020-06-01-listplans) +- [ListImages:镜像信息](https://help.aliyun.com/zh/simple-application-server/developer-reference/api-swas-open-2020-06-01-listimages) +- [计费项](https://help.aliyun.com/zh/simple-application-server/product-overview/billable-items) +- [挂载数据盘及使用限制](https://help.aliyun.com/zh/simple-application-server/user-guide/attach-a-data-disk) +- [升级套餐配置及限制](https://help.aliyun.com/zh/simple-application-server/user-guide/upgrade-a-simple-application-server) +- [最佳实践:快速部署 Docker](https://help.aliyun.com/zh/simple-application-server/use-cases/deploy-and-use-docker) +- [管理防火墙](https://help.aliyun.com/zh/simple-application-server/user-guide/manage-the-firewall-of-a-server) diff --git a/src/iac_code/tools/cloud/aliyun/openmeta.py b/src/iac_code/tools/cloud/aliyun/openmeta.py index da61247da..f8dd0fa97 100644 --- a/src/iac_code/tools/cloud/aliyun/openmeta.py +++ b/src/iac_code/tools/cloud/aliyun/openmeta.py @@ -8,9 +8,11 @@ import json import logging import os +import random import re import sys import tempfile +import time from collections import OrderedDict from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, replace @@ -25,6 +27,14 @@ from iac_code.a2a.backup import run_sync_fenced from iac_code.tools.cloud.aliyun.api_identifiers import is_safe_api_version +from iac_code.tools.cloud.aliyun.retry_policy import ( + RetryBudget, + RetryExhausted, + RetryReason, + map_httpx_retry_reason, + map_retryable_status, + retry_delay, +) from iac_code.tools.cloud.aliyun.user_agent import build_user_agent from iac_code.utils.async_lifecycle import await_task_to_completion from iac_code.utils.file_security import ensure_private_dir @@ -73,6 +83,7 @@ _API_FRESH_TTL = timedelta(days=7) _STALE_TTL = timedelta(days=30) _NEGATIVE_TTL = timedelta(minutes=10) +_REQUEST_TIMEOUT_SECONDS = 8.0 logger = logging.getLogger(__name__) @@ -1523,46 +1534,105 @@ async def _request_json( url: str, *, max_bytes: int, + ) -> tuple[_JsonPayload | None, OpenMetaError | None, str]: + budget = RetryBudget(deadline=time.monotonic() + _REQUEST_TIMEOUT_SECONDS, random=random.random) + started = time.monotonic() + path = httpx.URL(url).path + for _ in range(budget.max_attempts): + try: + await budget.acquire() + result = await budget.run_attempt( + lambda: self._request_json_once(url, max_bytes=max_bytes), + retryable_call=True, + ) + except RetryExhausted: + logger.warning( + "OpenMeta request deadline exhausted: path=%s attempt=%s elapsed_ms=%d", + path, + budget.attempts, + (time.monotonic() - started) * 1000, + ) + break + except httpx.HTTPError as error: + status = None + retry_after = None + reason = map_httpx_retry_reason(error) + if isinstance(error, httpx.HTTPStatusError): + status = error.response.status_code + retry_after = error.response.headers.get("Retry-After") + reason = RetryReason.RETRYABLE_STATUS if status == 500 else map_retryable_status(status) + logger.warning( + "OpenMeta request failed: path=%s attempt=%s status=%s error=%s elapsed_ms=%d", + path, + budget.attempts, + status, + type(error).__name__, + (time.monotonic() - started) * 1000, + ) + if reason is None or budget.attempts >= budget.max_attempts: + break + try: + delay = retry_delay(budget, failed_attempt=budget.attempts, retry_after=retry_after, reason=reason) + except RetryExhausted: + break + await asyncio.sleep(delay) + else: + if budget.attempts > 1: + logger.debug( + "OpenMeta request completed: path=%s attempt=%s outcome=%s elapsed_ms=%d", + path, + budget.attempts, + result[1] or "success", + (time.monotonic() - started) * 1000, + ) + return result + return None, "temporarily_unavailable", url + + async def _request_json_once( + self, + url: str, + *, + max_bytes: int, ) -> tuple[_JsonPayload | None, OpenMetaError | None, str]: current = httpx.URL(url) for _ in range(3): - try: - async with self._client.stream("GET", current) as response: - if response.status_code in {301, 302, 303, 307, 308}: - location = response.headers.get("location") - if location is None: - return None, "temporarily_unavailable", str(current) - candidate = current.join(location) - if not _safe_redirect(candidate): - return None, "temporarily_unavailable", str(current) - current = candidate - continue - if response.status_code in {204, 404}: - return None, "not_found", str(current) - if response.status_code == 429 or response.status_code >= 500: + async with self._client.stream("GET", current) as response: + if response.status_code in {301, 302, 303, 307, 308}: + location = response.headers.get("location") + if location is None: return None, "temporarily_unavailable", str(current) - if response.status_code != 200: - return None, "protocol_error", str(current) - content_length = response.headers.get("content-length") - if content_length is not None: - try: - if int(content_length) > max_bytes: - return None, "protocol_error", str(current) - except ValueError: - pass - chunks: list[bytes] = [] - received = 0 - async for chunk in response.aiter_bytes(): - received += len(chunk) - if received > max_bytes: + candidate = current.join(location) + if not _safe_redirect(candidate): + return None, "temporarily_unavailable", str(current) + current = candidate + continue + if response.status_code in {204, 404}: + return None, "not_found", str(current) + if response.status_code == 500 or map_retryable_status(response.status_code) is not None: + response.raise_for_status() + if response.status_code == 429 or response.status_code >= 500: + return None, "temporarily_unavailable", str(current) + if response.status_code != 200: + return None, "protocol_error", str(current) + content_length = response.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > max_bytes: return None, "protocol_error", str(current) - chunks.append(chunk) - except httpx.HTTPError: - return None, "temporarily_unavailable", str(current) + except ValueError: + pass + chunks: list[bytes] = [] + received = 0 + async for chunk in response.aiter_bytes(): + received += len(chunk) + if received > max_bytes: + return None, "protocol_error", str(current) + chunks.append(chunk) try: async with self._decode_semaphore: payload = await run_sync_fenced(json.loads, b"".join(chunks)) except (json.JSONDecodeError, UnicodeDecodeError): + logger.warning("OpenMeta response is not valid JSON: path=%s", current.path) return None, "temporarily_unavailable", str(current) if not isinstance(payload, Mapping | list): return None, "protocol_error", str(current) diff --git a/src/iac_code/tools/cloud/aliyun/public_errors.py b/src/iac_code/tools/cloud/aliyun/public_errors.py index f896cded1..347e565df 100644 --- a/src/iac_code/tools/cloud/aliyun/public_errors.py +++ b/src/iac_code/tools/cloud/aliyun/public_errors.py @@ -140,9 +140,11 @@ def public_aliyun_error( operation=operation ) if code == "metadata_unavailable": - return _("Alibaba Cloud API metadata for {operation} is temporarily unavailable; try again later.").format( - operation=operation - ) + return _( + "Alibaba Cloud API metadata for {operation} is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported." + ).format(operation=operation) if code == "metadata_protocol_error": return _( "Alibaba Cloud API metadata for {operation} returned an incompatible response. " diff --git a/src/iac_code/web/static/diagram-preview.html b/src/iac_code/web/static/diagram-preview.html index a4680ff42..130f42a65 100644 --- a/src/iac_code/web/static/diagram-preview.html +++ b/src/iac_code/web/static/diagram-preview.html @@ -106,6 +106,6 @@

- + diff --git a/src/iac_code/web/static/index.html b/src/iac_code/web/static/index.html index 6d25083b7..eb78c77e9 100644 --- a/src/iac_code/web/static/index.html +++ b/src/iac_code/web/static/index.html @@ -479,6 +479,6 @@

- + diff --git a/src/iac_code/web/static/js/app.js b/src/iac_code/web/static/js/app.js index d3f051cc7..0fdbdc868 100644 --- a/src/iac_code/web/static/js/app.js +++ b/src/iac_code/web/static/js/app.js @@ -8,14 +8,14 @@ import { deploymentConfirmationKey, renderDeploymentConfirmationPanel, renderPipelineWorkspace, -} from "./components/pipeline.js?v=pipeline-solution-confirm-v16"; +} from "./components/pipeline.js?v=pipeline-solution-confirm-v17"; import { renderToolCards, applyShimmerPhase, applySpinPhase } from "./components/tool_cards.js?v=live-inline-tools-v26"; import { createWorkspaceController } from "./components/workspace.js?v=cloud-creds-v60"; -import { createOutputController, highlightTemplate } from "./components/output_panel.js?v=output-panel-v38"; +import { createOutputController, highlightTemplate } from "./components/output_panel.js?v=output-panel-v39"; import { openImageLightbox } from "./components/image_lightbox.js?v=image-lightbox-v1"; import { reduceEvent } from "./events.js?v=web-repl-ui-324"; import { applyDomI18n, t } from "./i18n.js?v=web-repl-ui-277"; -import { renderMermaid } from "./mermaid_render.js?v=arch-diagram-v5"; +import { renderMermaid } from "./mermaid_render.js?v=cost-period-v6"; const root = document.getElementById("iac-code-web-root"); const DESKTOP_RUNTIME = diff --git a/src/iac_code/web/static/js/architecture_diagram.js b/src/iac_code/web/static/js/architecture_diagram.js index 3e2ace9d3..7b835c932 100644 --- a/src/iac_code/web/static/js/architecture_diagram.js +++ b/src/iac_code/web/static/js/architecture_diagram.js @@ -1,5 +1,5 @@ import { t } from "./i18n.js?v=web-repl-ui-277"; -import { renderMermaid } from "./mermaid_render.js?v=arch-diagram-v5"; +import { renderMermaid } from "./mermaid_render.js?v=cost-period-v6"; import { getArchitectureDiagramRenderer } from "./api.js?v=web-repl-ui-313"; const RENDERER_EVENT = "iac-code:architecture-renderer-changed"; diff --git a/src/iac_code/web/static/js/components/output_panel.js b/src/iac_code/web/static/js/components/output_panel.js index 66eccf05c..c32c858de 100644 --- a/src/iac_code/web/static/js/components/output_panel.js +++ b/src/iac_code/web/static/js/components/output_panel.js @@ -3,8 +3,8 @@ import { t } from "../i18n.js?v=web-repl-ui-277"; import { disposeArchitectureDiagram, renderArchitectureDiagram, -} from "../architecture_diagram.js?v=eraser-browser-v14"; -import { renderDiagramPrice } from "../mermaid_render.js?v=arch-diagram-v5"; +} from "../architecture_diagram.js?v=eraser-browser-v15"; +import { renderDiagramPrice } from "../mermaid_render.js?v=cost-period-v6"; const STATUS_LABELS = { true: t("Success"), false: t("In progress / failed") }; const DIAGRAM_PREVIEW_MIN_WIDTH = 560; diff --git a/src/iac_code/web/static/js/components/pipeline.js b/src/iac_code/web/static/js/components/pipeline.js index 6f6c2100a..b32d6e613 100644 --- a/src/iac_code/web/static/js/components/pipeline.js +++ b/src/iac_code/web/static/js/components/pipeline.js @@ -2,8 +2,8 @@ import { t } from "../i18n.js?v=web-repl-ui-277"; import { disposeArchitectureDiagram, renderArchitectureDiagram, -} from "../architecture_diagram.js?v=eraser-browser-v14"; -import { renderDiagramPrice } from "../mermaid_render.js?v=arch-diagram-v5"; +} from "../architecture_diagram.js?v=eraser-browser-v15"; +import { renderDiagramPrice } from "../mermaid_render.js?v=cost-period-v6"; function text(value) { return value === undefined || value === null ? "" : String(value); diff --git a/src/iac_code/web/static/js/diagram_preview.js b/src/iac_code/web/static/js/diagram_preview.js index 208d06cb7..15a6b4a06 100644 --- a/src/iac_code/web/static/js/diagram_preview.js +++ b/src/iac_code/web/static/js/diagram_preview.js @@ -1,4 +1,4 @@ -import { renderArchitectureDiagram } from "./architecture_diagram.js?v=eraser-browser-v14"; +import { renderArchitectureDiagram } from "./architecture_diagram.js?v=eraser-browser-v15"; import { indexTemplateFiles, shuffledOtherTemplatePaths, diff --git a/src/iac_code/web/static/js/mermaid_render.js b/src/iac_code/web/static/js/mermaid_render.js index 4abf3d187..847be793c 100644 --- a/src/iac_code/web/static/js/mermaid_render.js +++ b/src/iac_code/web/static/js/mermaid_render.js @@ -108,7 +108,7 @@ export function renderDiagramPrice(item) { totalRow.className = "diagram-price-total"; const label = document.createElement("span"); label.className = "diagram-price-label"; - label.textContent = t("Estimated monthly cost"); + label.textContent = t("Estimated cost"); const value = document.createElement("span"); value.className = "diagram-price-value"; value.textContent = total || "—"; diff --git a/tests/a2a/test_pipeline_snapshot.py b/tests/a2a/test_pipeline_snapshot.py index da0a57819..f77129fe3 100644 --- a/tests/a2a/test_pipeline_snapshot.py +++ b/tests/a2a/test_pipeline_snapshot.py @@ -1327,8 +1327,8 @@ def test_solution_first_canonical_conclusion_keeps_full_candidate_in_snapshot() ], "rough_cost": { "currency": "CNY", - "monthly_range": "¥100/月", - "items": [{"name": "ECS", "spec": "ecs.e-c1m1.large", "monthly_cost": "¥100/月"}], + "cost_range": "¥100/月", + "items": [{"name": "ECS", "spec": "ecs.e-c1m1.large", "cost": "¥100/月"}], "assumptions": ["杭州地域"], "exclusions": ["流量费"], "confidence": "medium", diff --git a/tests/a2a/test_transport_dispatcher.py b/tests/a2a/test_transport_dispatcher.py index 2f885e3ff..e2fc676a9 100644 --- a/tests/a2a/test_transport_dispatcher.py +++ b/tests/a2a/test_transport_dispatcher.py @@ -2578,6 +2578,53 @@ async def reconcile(_params, _context) -> None: assert finalized is False +@pytest.mark.asyncio +async def test_inactive_input_response_projects_final_reply_and_consumed_permission() -> None: + call_context = ServerCallContext() + task = Task( + id="task-reply", context_id="ctx-reply", + status=TaskStatus(state=TaskState.TASK_STATE_INPUT_REQUIRED, + message=Message(role=Role.ROLE_AGENT, parts=[Part(text="permission needed")])), + ) + ParseDict({"iac_code": {"input": {"kind": "permission"}}}, task.metadata) + store = A2ATaskStore() + await store.save(task, call_context) + + class RequestContextBuilder: + async def build(self, **_kwargs): + return SimpleNamespace(call_context=call_context) + + class Executor: + async def execute(self, _request_context, event_queue): + final = TaskStatusUpdateEvent( + task_id=task.id, context_id=task.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_WORKING, + message=Message(role=Role.ROLE_AGENT, parts=[Part(text="final reply")])), + ) + ParseDict({"iac_code": {"assistantFinal": {"complete": True}}}, final.metadata) + await event_queue.enqueue_event(final) + await event_queue.enqueue_event(TaskStatusUpdateEvent( + task_id=task.id, context_id=task.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_INPUT_REQUIRED), + )) + + handler = IacCodeRequestHandler.__new__(IacCodeRequestHandler) + handler.task_store = store + handler.agent_executor = Executor() + handler._request_context_builder = RequestContextBuilder() + handler._detached_message_producers = set() + params = SimpleNamespace(message=SimpleNamespace(task_id=task.id, context_id=task.context_id), + configuration=None) + + events = await _collect_async(handler._on_inactive_input_response_send_stream(params, call_context, task=task)) + + projected = await store.get(task.id, call_context) + assert projected is not None and len(events) == 2 + assert projected.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + assert any(part.text == "final reply" for message in projected.history for part in message.parts) + assert "input" not in dict(projected.metadata["iac_code"]) + + @pytest.mark.asyncio async def test_detached_permission_response_finalizes_after_terminal_producer_drains() -> None: call_context = ServerCallContext() @@ -2627,6 +2674,8 @@ async def finalize_natural_execution(self, **kwargs) -> dict: await asyncio.wait_for(finalized.wait(), timeout=1) await asyncio.gather(*handler._detached_message_producers) + projected = await handler.task_store.get(task.id, call_context) + assert projected is not None and projected.status.state == TaskState.TASK_STATE_COMPLETED @pytest.mark.asyncio @@ -6642,6 +6691,10 @@ async def handle(self, request): class _PublicStoppedPipelineFixture: """Exercise full SDK dispatch and real Runner/Stop/durable successor claim.""" + # Initial dispatch and recovery both construct real runners and persist + # session/backup state. They use the same integration readiness budget, + # separate from the five-second mock-stream delivery assertions. + model_ready_timeout = 20 task_id = "task-public-stop" context_id = "ctx-public-stop" original_text = "Remember planning marker stopped-sdk and network 10.242.0.0/16." @@ -6777,6 +6830,23 @@ def send(self, request, *, close_at_canceled=False): self.sending.append(sending) return sending + async def wait_for_model(self, ready, sending) -> None: + waiter = asyncio.create_task(ready.wait()) + try: + done, _ = await asyncio.wait( + (waiter, sending), timeout=self.model_ready_timeout, return_when=asyncio.FIRST_COMPLETED + ) + if sending in done: + # Preserve an early producer exception rather than reporting a + # misleading model-start timeout after the stream already died. + await sending + raise AssertionError("Pipeline stream ended before its blocked model call was reached") + assert waiter in done, "Pipeline did not reach its blocked model call within the readiness budget" + await waiter + finally: + waiter.cancel() + await asyncio.gather(waiter, return_exceptions=True) + async def prepare_stopped(self) -> None: ctx = await self.store.get_or_create_context( context_id=self.context_id, @@ -6791,7 +6861,7 @@ async def prepare_stopped(self) -> None: ) old_request = self.request(task_id=self.task_id, message_id="public-stop-first", text=self.original_text) first = self.send(old_request, close_at_canceled=True) - await asyncio.wait_for(self.model.first_model.wait(), timeout=_STREAM_TEST_TIMEOUT) + await self.wait_for_model(self.model.first_model, first) self.control = self.service.get_for_context(self.context_id) assert self.control is not None and self.control.task_id == self.task_id ctx = await self.store.get_context_record(self.context_id) @@ -6885,9 +6955,7 @@ async def test_public_sdk_pipeline_stop_reentry_omitted_task_uses_real_stop_proo assert fixture.model.business_calls == 1 else: sending = fixture.send(request) - # This path replays real Pipeline/SDK recovery and durable writes; - # Windows file IO can exceed the mock-stream tests' five seconds. - await asyncio.wait_for(fixture.model.resumed_model.wait(), timeout=20) + await fixture.wait_for_model(fixture.model.resumed_model, sending) successor = request.message.task_id assert successor and successor != fixture.task_id intent = intent_store.load(context_id=fixture.context_id) diff --git a/tests/agent/test_agent_loop_new.py b/tests/agent/test_agent_loop_new.py index cd9b35277..d24a5a67b 100644 --- a/tests/agent/test_agent_loop_new.py +++ b/tests/agent/test_agent_loop_new.py @@ -145,6 +145,18 @@ def test_tool_result_block_metadata_filters_render_metadata_to_json_safe_fields( json.dumps(metadata) +@pytest.mark.parametrize("candidate_set_id", ["outline-original", "", None, 123, {"id": "outline"}]) +def test_tool_result_block_metadata_preserves_only_valid_candidate_set_id(candidate_set_id): + metadata = AgentLoop._tool_result_block_metadata( + SimpleNamespace(is_externalized=False), + {"candidate_set_id": candidate_set_id, "idempotent": True, "unrelated": object()}, + ) + + expected = {"candidate_set_id": candidate_set_id} if candidate_set_id == "outline-original" else {} + assert metadata == expected + json.dumps(metadata) + + def _marked_aliyun_http(**changes): value = { "contract_version": "aliyun_body_v1", diff --git a/tests/pipeline/engine/test_step_executor.py b/tests/pipeline/engine/test_step_executor.py index d67993980..73abd809a 100644 --- a/tests/pipeline/engine/test_step_executor.py +++ b/tests/pipeline/engine/test_step_executor.py @@ -1650,6 +1650,44 @@ def test_empty_prompt_file_with_skill(self, tmp_path): class TestStepExecutorBaseSections: + def test_step_runtime_facts_render_in_skill_and_step_without_entering_base_sections(self, tmp_path): + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts/test.md").write_text("Task in {host.region}", encoding="utf-8") + calls = [] + + def runtime_facts(): + calls.append(True) + return {"host": {"region": "cn-beijing"}} + + step = StepSpec( + step_id="test", + conclusion_field="result", + forward=None, + prompt_file="prompts/test.md", + skill="test-skill", + prompt_context_builder=runtime_facts, + ) + pipeline = LoadedPipeline( + name="test", + steps=[step], + context_dependencies={"result": []}, + max_rollbacks=3, + skills={"test-skill": 'Skill in {host.region}; literal JSON: {"status": "ready"}'}, + base_prompt_sections=IncludeExcludeConfig(include=["identity"]), + ) + executor = StepExecutor( + provider_manager=MagicMock(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=tmp_path, + ) + prompt = executor._build_full_system_prompt(step, PipelineContext({"result": []})) + base, skill, task = prompt.split("\n\n---\n\n") + assert "cn-beijing" not in base + assert 'Skill in cn-beijing; literal JSON: {"status": "ready"}' in skill + assert task == "Task in cn-beijing" + assert calls == [True] + def test_system_prompt_includes_base_sections(self, tmp_path): """Base sections appear before skill content in system prompt.""" (tmp_path / "prompts").mkdir(exist_ok=True) diff --git a/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py b/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py new file mode 100644 index 000000000..2d5f9d321 --- /dev/null +++ b/tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py @@ -0,0 +1,217 @@ +"""Deterministic P18 regression through the real pipeline, tools and disk recovery.""" + +from contextlib import aclosing +from pathlib import Path + +import pytest +import yaml + +from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.pipeline.engine.pipeline_runner import PipelineRunner +from iac_code.pipeline.engine.ui_contract import encode_selected_candidate +from iac_code.services.session_storage import SessionStorage +from iac_code.tools.base import ToolRegistry +from iac_code.types.stream_events import ( + MessageEndEvent, + MessageStartEvent, + ToolResultEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, +) + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration, pytest.mark.timeout(60)] +PIPELINE = Path(__file__).resolve().parents[3] / "src/iac_code/pipeline/selling_solution_first" +NAME = "Managed database" +BATCH_ID = "outline-original" +REPEAT_ID = "outline-idempotent-repeat" +OUTLINE = { + "candidates": [ + { + "candidate_name": NAME, + "summary": "One managed RDS instance in Hangzhou", + "total_cost": "CNY 800-1200/month", + "key_tradeoff": "Fixed monthly cost", + } + ], +} +DETAIL = { + "candidate_index": 0, + "candidate_name": NAME, + "applicable_scenarios": ["Managed relational database"], + "resource_intents": [{"product": "RDS", "action": "create", "role": "database"}], + "topology_graph": {"nodes": [{"id": "rds", "label": "RDS", "product": "RDS"}], "edges": []}, + "resource_inventory": [ + { + "resource_id": "rds", + "product": "RDS", + "purpose": "database", + "quantity": 1, + "rough_cost": "CNY 800-1200/month", + "lifecycle": "create", + } + ], + "cost_assumptions": ["cn-hangzhou"], + "cost_exclusions": [], + "cost_confidence": "medium", + "decision_notes": { + "why_recommended": ["Meets the managed database request"], + "problems_solved": ["Avoid manual database maintenance"], + "pros": ["Managed backups", "Managed monitoring"], + "cons": ["Fixed monthly cost"], + }, +} + + +class PlanningModel: + """Only model output is scripted; no display, persistence or recovery mocks.""" + + def __init__(self): + self.calls = 0 + self.materialization_inspected = False + + def get_model_name(self): + return "deterministic-p18-fixture" + + async def stream(self, messages, system, tools=None, max_tokens=8192): + script = [ + (BATCH_ID, "show_architecture_plan", OUTLINE), + ("candidate-detail", "show_candidate_detail", DETAIL), + (REPEAT_ID, "show_architecture_plan", OUTLINE), + ( + "wait-for-selection", + "complete_step", + { + "conclusion": { + "status": "awaiting_selection", + "intent": { + "cloud_platform": "aliyun", + "resource_intents": [{"product": "RDS", "action": "create", "source": "user"}], + "hard_constraints": [], + }, + }, + }, + ), + # If deterministic selection falls back, attempt the same valid + # choice through the model so the real rejection is visible. + ( + "select-candidate", + "complete_step", + { + "conclusion": {"status": "selected", "selected_candidate_index": 0}, + }, + ), + ] + if self.calls == len(script) - 1 and "第二步" in system: + assert all(key not in system for key in ("monthly_range", "rough_monthly_cost", "monthly_cost")) + assert "CNY 800-1200/month" in system + self.materialization_inspected = True + yield MessageStartEvent(message_id="materialization-prompt-checked") + return + assert self.calls < len(script), ( + "Valid selection fell back to the model instead of advancing", + [ + (block.tool_use_id, block.content) + for message in messages + if isinstance(message.content, list) + for block in message.content + if block.type == "tool_result" + ], + ) + call_id, name, arguments = script[self.calls] + self.calls += 1 + yield MessageStartEvent(message_id=f"planning-{self.calls}") + yield ToolUseStartEvent(tool_use_id=call_id, name=name) + yield ToolUseEndEvent(tool_use_id=call_id, name=name, input=arguments) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + +@pytest.mark.parametrize("mode", ["same-process", "cold-recovery", "legacy-cold-recovery"]) +async def test_idempotent_outline_survives_selection_resume(tmp_path, monkeypatch, mode): + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + workspace = tmp_path / "workspace" + workspace.mkdir() + model = PlanningModel() + + def runner(*, resume=False): + return PipelineRunner( + pipeline_dir=PIPELINE, + provider_manager=model, + base_tool_registry=ToolRegistry(), + session_storage=SessionStorage(tmp_path / "projects"), + session_id="p18-regression", + cwd=str(workspace), + resume_from_sidecar=resume, + ) + + flow = runner() + events = [event async for event in flow.run("Plan a managed RDS database in Hangzhou")] + results = {event.tool_use_id: event for event in events if isinstance(event, ToolResultEvent)} + assert set(results) == {BATCH_ID, "candidate-detail", REPEAT_ID, "wait-for-selection"} + assert not any(result.is_error for result in results.values()), results + assert results[REPEAT_ID].metadata["idempotent"] is True + assert results[REPEAT_ID].metadata["candidate_set_id"] == BATCH_ID + saved = flow.context.get_conclusion("solution_selection") + assert saved["status"] == "awaiting_selection" + assert saved["candidate_set_id"] == BATCH_ID + assert any( + isinstance(event, PipelineEvent) and event.type == PipelineEventType.USER_INPUT_REQUIRED for event in events + ) + assert model.calls == 4 + + if mode == "legacy-cold-recovery": + # Simulate a sidecar written by the previous version, keeping state/version metadata intact. + context_path = flow.session.context_path + snapshot = yaml.safe_load(context_path.read_text(encoding="utf-8")) + for candidate in snapshot["solution_selection"]["value"]["candidates"]: + cost = candidate["rough_cost"] + cost["monthly_range"] = cost.pop("cost_range") + for item in cost["items"]: + item["monthly_cost"] = item.pop("cost") + for resource in candidate["resource_inventory"]: + resource["rough_monthly_cost"] = resource.pop("rough_cost") + context_path.write_text(yaml.safe_dump(snapshot, allow_unicode=True), encoding="utf-8") + if mode != "same-process": + # Construct a new runner/storage: restore the real sidecar and attempt + # transcript from disk, without carrying the live completion guard. + flow = runner(resume=True) + restored = flow.context.get_conclusion("solution_selection") + if mode == "legacy-cold-recovery": + assert "monthly_range" in restored["candidates"][0]["rough_cost"] + else: + assert restored == saved + + resumed = [] + async with aclosing(flow.resume(encode_selected_candidate(NAME, 0))) as stream: + async for event in stream: + resumed.append(event) + if isinstance(event, ToolResultEvent): + assert not event.is_error, event.result + if ( + isinstance(event, PipelineEvent) + and event.type == PipelineEventType.STEP_STARTED + and event.step_id == "materialize_selected_candidate" + and mode != "legacy-cold-recovery" + ): + break # The selection boundary is verified; do not create cloud resources. + if isinstance(event, MessageStartEvent) and event.message_id == "materialization-prompt-checked": + break + + selected = flow.context.get_conclusion("solution_selection") + assert selected["status"] == "selected", ( + "P18: an unchanged candidate batch was rejected after transcript recovery", + resumed, + ) + assert selected["candidate_set_id"] == BATCH_ID + assert selected["selected_candidate_index"] == 0 + assert selected["selected_candidate"] == saved["candidates"][0] + assert model.calls == 4, "Selection must not regenerate candidates or call the model again" + if mode == "legacy-cold-recovery": + assert model.materialization_inspected, "Verify the real Step 2 prompt after legacy disk recovery" + assert any( + isinstance(event, PipelineEvent) + and event.type == PipelineEventType.STEP_STARTED + and event.step_id == "materialize_selected_candidate" + for event in resumed + ) diff --git a/tests/pipeline/selling_solution_first/test_completion_projection.py b/tests/pipeline/selling_solution_first/test_completion_projection.py index 5bdb32f37..e3dbedfe2 100644 --- a/tests/pipeline/selling_solution_first/test_completion_projection.py +++ b/tests/pipeline/selling_solution_first/test_completion_projection.py @@ -133,8 +133,8 @@ def _candidate_semantics() -> dict[str, Any]: ], "rough_cost": { "currency": "CNY", - "monthly_range": "¥800~¥1200/月", - "items": [{"name": "RDS", "monthly_cost": "¥800~¥1200/月"}], + "cost_range": "¥800~¥1200/月", + "items": [{"name": "RDS", "cost": "¥800~¥1200/月"}], "assumptions": ["cn-hangzhou"], "exclusions": [], "confidence": "medium", @@ -167,7 +167,7 @@ def _planning_records( { "candidate_name": str(candidate.get("name") or ""), "summary": str(candidate.get("summary") or ""), - "total_monthly_cost": str(rough_cost.get("monthly_range") or ""), + "total_cost": str(rough_cost.get("cost_range") or ""), "key_tradeoff": (str(cons[0]).strip() if cons else "") or "待进一步评估", } ) @@ -627,6 +627,28 @@ async def test_raw_and_runtime_failures_share_the_same_retry_budget(self): class TestStepOneProjection: + def test_legacy_outline_and_detail_records_project_into_neutral_costs(self, loaded): + candidate = _candidate_semantics() + candidate["resource_inventory"][0]["rough_cost"] = "¥8~¥12/小时" + candidate["rough_cost"]["cost_range"] = "¥8~¥12/小时" + records = _planning_records([candidate]) + for outline in records[0]["input"]["candidates"]: + outline["total_monthly_cost"] = outline.pop("total_cost") + for record in records[1:]: + for resource in record["input"].get("resource_inventory", []): + resource["rough_monthly_cost"] = resource.pop("rough_cost") + original = copy.deepcopy(records) + + result = _finalize( + _tool(_step(loaded, "solution_planning_and_selection"), records=records), + _awaiting_selection_delta(), + ) + projected = result.conclusion["candidates"][0] + assert projected["rough_cost"]["cost_range"] == "¥8~¥12/小时" + assert projected["rough_cost"]["items"][0]["cost"] == "¥8~¥12/小时" + assert projected["resource_inventory"][0]["rough_cost"] == "¥8~¥12/小时" + assert records == original, "Migration must not rewrite the historical audit records" + def test_awaiting_and_selected_deltas_expand_to_authoritative_candidates(self, loaded): step = _step(loaded, "solution_planning_and_selection") records = _planning_records([_candidate_semantics()]) @@ -818,12 +840,13 @@ def test_completion_error_reports_at_most_five_batch_problems(self, loaded): error = _assert_error(_tool(step, records=records), _awaiting_selection_delta(), "fully detailed") assert "1 more error(s) omitted" in error.message - def test_option_summary_keeps_semicolon_prose_and_replaces_old_price_tail(self, loaded): + @pytest.mark.parametrize("unit", ["月", "小时", "天", "周", "年", "hour", "day", "week", "year"]) + def test_option_summary_keeps_semicolon_prose_and_replaces_old_price_tail(self, loaded, unit): step = _step(loaded, "solution_planning_and_selection") candidate = _candidate_semantics() base = "复用现有网络;减少资源数量;适合测试环境" - candidate["summary"] = f"{base};¥80~¥300/月;旧代价" - candidate["rough_cost"]["monthly_range"] = "¥100~¥360/月" + candidate["summary"] = f"{base};¥80~¥300/{unit};旧代价" + candidate["rough_cost"]["cost_range"] = f"¥100~¥360/{unit}" candidate["decision_notes"]["cons"] = ["新代价"] summary = _finalize( @@ -831,7 +854,7 @@ def test_option_summary_keeps_semicolon_prose_and_replaces_old_price_tail(self, _awaiting_selection_delta(), ).conclusion["options"][0]["summary"] - assert summary == f"{base};¥100~¥360/月;新代价" + assert summary == f"{base};¥100~¥360/{unit};新代价" def test_status_only_awaiting_delta_reopens_saved_candidates_but_cannot_create_them(self, loaded): step = _step(loaded, "solution_planning_and_selection") diff --git a/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py b/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py new file mode 100644 index 000000000..b96bb53dd --- /dev/null +++ b/tests/pipeline/selling_solution_first/test_cost_field_compatibility.py @@ -0,0 +1,138 @@ +"""Period-neutral model contracts, persisted candidates and legacy display clients.""" + +import asyncio +import copy +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from iac_code.pipeline.engine.context import PipelineContext +from iac_code.pipeline.engine.loader import load_pipeline_dir +from iac_code.pipeline.engine.step_executor import StepExecutor +from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import normalize_candidate_costs +from iac_code.pipeline.selling_solution_first.tools.show_architecture_plan_tool import ShowArchitecturePlanTool +from iac_code.pipeline.selling_solution_first.tools.show_candidate_detail_tool import ShowCandidateDetailTool +from iac_code.tools.base import ToolContext, ToolRegistry +from iac_code.types.stream_events import CandidateDetailEvent + +PIPELINE = Path(__file__).resolve().parents[3] / "src/iac_code/pipeline/selling_solution_first" +LEGACY_FIELDS = {"total_monthly_cost", "rough_monthly_cost", "monthly_range", "monthly_cost"} + + +def legacy_candidate(): + return { + "candidate_id": "candidate-0", "name": "GPU", "summary": "Eight-hour GPU test", + "output_path": "templates/0-gpu.yml", + "resource_inventory": [{"resource_id": "gpu", "rough_monthly_cost": "¥240/8小时"}], + "rough_cost": { + "monthly_range": "¥240/8小时", "items": [{"name": "ECS", "monthly_cost": "¥240/8小时"}], + }, + } + + +@pytest.mark.parametrize("step_id", ["materialize_selected_candidate", "deploying"]) +def test_restored_candidates_are_neutral_before_rendering_prompts(step_id, tmp_path, monkeypatch): + monkeypatch.delenv("IAC_CODE_ROS_AGENT_AWARENESS", raising=False) + pipeline = load_pipeline_dir(PIPELINE) + ctx = PipelineContext(pipeline.context_dependencies) + candidate = legacy_candidate() + ctx.set_conclusion("solution_selection", { + "status": "selected", "continue_pipeline": True, + "candidates": [candidate], "selected_candidate": copy.deepcopy(candidate), + "selected_candidate_index": 0, "selected_candidate_name": "GPU", + }) + field = ctx.get_field("solution_selection") + field.stale = True + metadata = (field.version, field.stale, field.updated_at, copy.deepcopy(field.history)) + step = next(step for step in pipeline.steps if step.step_id == step_id) + step.on_enter(ctx) + + selection = ctx.get_conclusion("solution_selection") + for value in [selection["candidates"][0], selection["selected_candidate"]]: + assert value["rough_cost"]["cost_range"] == "¥240/8小时" + assert value["rough_cost"]["items"][0]["cost"] == "¥240/8小时" + assert value["resource_inventory"][0]["rough_cost"] == "¥240/8小时" + assert not any(key in json.dumps(value) for key in LEGACY_FIELDS) + assert (field.version, field.stale, field.updated_at, field.history) == metadata + assert candidate == legacy_candidate(), "Do not mutate retained historical candidate references" + + executor = StepExecutor( + provider_manager=SimpleNamespace(), base_tool_registry=ToolRegistry(), + pipeline=pipeline, pipeline_dir=PIPELINE, cwd=str(tmp_path), + ) + prompt = executor._build_full_system_prompt(step, ctx) + assert not any(key in prompt for key in LEGACY_FIELDS) + if step_id == "materialize_selected_candidate": + assert "¥240/8小时" in prompt + assert selection["selection_valid"] is True + + +def test_canonical_values_including_zero_take_precedence_over_legacy_values(): + candidate = legacy_candidate() + candidate["resource_inventory"][0]["rough_cost"] = "¥0/小时" + candidate["rough_cost"]["cost_range"] = "¥0/小时" + candidate["rough_cost"]["items"][0]["cost"] = 0 + migrated = normalize_candidate_costs(candidate) + assert migrated["rough_cost"]["cost_range"] == "¥0/小时" + assert migrated["rough_cost"]["items"][0]["cost"] == 0 + assert migrated["resource_inventory"][0]["rough_cost"] == "¥0/小时" + assert normalize_candidate_costs(migrated) == migrated + + +@pytest.mark.asyncio +async def test_new_outline_matches_legacy_record_without_changing_candidate_batch(): + legacy = {"candidate_name": "GPU", "summary": "8h GPU", "total_monthly_cost": "¥240/8小时", + "key_tradeoff": "Compute only"} + state = {"tool_result_records": [{ + "tool_name": "show_architecture_plan", "input": {"candidates": [legacy]}, + "is_error": False, "record_id": "original-batch", "sequence": 1, + }]} + current = {**legacy, "total_cost": legacy["total_monthly_cost"]} + del current["total_monthly_cost"] + queue = asyncio.Queue() + result = await ShowArchitecturePlanTool(state).execute( + tool_input={"candidates": [current]}, context=ToolContext(event_queue=queue, tool_use_id="new-call"), + ) + assert not result.is_error + assert result.metadata == {"candidate_set_id": "original-batch", "idempotent": True} + assert queue.empty() + + +@pytest.mark.asyncio +async def test_neutral_tools_keep_costs_in_the_existing_display_event_contract(): + outline = {"candidate_name": "GPU", "summary": "8h GPU", "total_cost": "¥240/8小时", + "key_tradeoff": "Compute only"} + state = {"tool_result_records": [{ + "tool_name": "show_architecture_plan", "input": {"candidates": [outline]}, + "is_error": False, "record_id": "batch", "sequence": 1, + }]} + queue = asyncio.Queue() + result = await ShowCandidateDetailTool(state).execute( + tool_input={ + "candidate_name": "GPU", "candidate_index": 0, + "topology_graph": {"nodes": [{"id": "gpu", "label": "GPU", "product": "ECS"}], "edges": []}, + "resource_inventory": [{"resource_id": "gpu", "product": "ECS", "rough_cost": "¥240/8小时"}], + }, context=ToolContext(event_queue=queue, tool_use_id="detail"), + ) + assert not result.is_error + event = queue.get_nowait() + assert isinstance(event, CandidateDetailEvent) + assert event.total_monthly_cost == "¥240/8小时" + assert event.cost_items == [{"name": "ECS", "spec": "", "monthly_cost": "¥240/8小时"}] + + +def test_all_current_model_contracts_and_skill_examples_avoid_legacy_cost_fields(): + pipeline = load_pipeline_dir(PIPELINE) + skill_root = PIPELINE / "skills/iac-aliyun-solution-first" + contracts = [ + ShowArchitecturePlanTool().input_schema, ShowCandidateDetailTool().input_schema, + pipeline.steps[0].conclusion_schema, + ] + rendered = ( + json.dumps(contracts) + + (skill_root / "SKILL.md").read_text(encoding="utf-8") + + (skill_root / "evals.json").read_text(encoding="utf-8") + ) + assert not any(key in rendered for key in LEGACY_FIELDS) diff --git a/tests/pipeline/selling_solution_first/test_packaging.py b/tests/pipeline/selling_solution_first/test_packaging.py index bf26814e3..09af30d89 100644 --- a/tests/pipeline/selling_solution_first/test_packaging.py +++ b/tests/pipeline/selling_solution_first/test_packaging.py @@ -22,7 +22,11 @@ SELLING_REFERENCES = PROJECT_ROOT / "src" / "iac_code" / "pipeline" / "selling" / "references" SELLING_DEPLOYING_SKILL = PROJECT_ROOT / "src" / "iac_code" / "pipeline" / "selling" / "skills" / "iac-aliyun-deploying" BUNDLED_REFERENCES = PROJECT_ROOT / "src" / "iac_code" / "skills" / "bundled" / "iac_aliyun" / "references" -SKILLS_WITH_REFERENCES = ("iac-aliyun-deploying", "iac-aliyun-materialize-selected-candidate") +SKILLS_WITH_REFERENCES = ( + "iac-aliyun-solution-first", + "iac-aliyun-deploying", + "iac-aliyun-materialize-selected-candidate", +) # canonical 来源:只有 pipeline 自己的参数推荐规则来自 selling/references,其余来自 bundled 技能。 CANONICAL_ROOTS = { "template-parameter-recommendation.md": SELLING_REFERENCES, @@ -147,13 +151,9 @@ def test_references_are_byte_identical_to_the_canonical_content(self, skill_name assert path.read_bytes() == canonical.read_bytes(), path assert len(relative_files) >= 11 - def test_both_skills_resolve_the_same_reference_tree(self): - first, second = ( - _reference_target(PIPELINE_DIR / "skills" / name / "references") for name in SKILLS_WITH_REFERENCES - ) - - assert first == SELLING_REFERENCES.resolve() - assert second == SELLING_REFERENCES.resolve() + def test_all_skills_resolve_the_same_reference_tree(self): + for name in SKILLS_WITH_REFERENCES: + assert _reference_target(PIPELINE_DIR / "skills" / name / "references") == SELLING_REFERENCES.resolve() @pytest.mark.parametrize("skill_name", SKILLS_WITH_REFERENCES) def test_every_reference_link_in_the_skill_resolves(self, skill_name): @@ -168,12 +168,14 @@ def test_every_reference_link_in_the_skill_resolves(self, skill_name): relative = Path(link).relative_to("references") assert (references / relative).exists(), f"{skill_name}: {link}" - def test_step_one_skill_does_not_reference_template_references(self): + def test_step_one_links_product_guides_without_template_references(self): skill_dir = PIPELINE_DIR / "skills" / "iac-aliyun-solution-first" + text = (skill_dir / "SKILL.md").read_text(encoding="utf-8") + links = set(REFERENCE_LINK_PATTERN.findall(text)) - # Step 1 不生成模板,因此不引用 references/,也不需要复制一份参考目录。 - assert not (skill_dir / "references").exists() - assert "references/" not in (skill_dir / "SKILL.md").read_text(encoding="utf-8") + # Product knowledge is read on demand; template/pricing workflows stay in Step 2. + assert links == {"references/cloud-products/", "references/coupons.md"} + assert "OriginPrice" not in text class TestEncoding: diff --git a/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py b/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py new file mode 100644 index 000000000..07229f29e --- /dev/null +++ b/tests/pipeline/selling_solution_first/test_ros_agent_awareness.py @@ -0,0 +1,198 @@ +"""Offline contracts for optional ROS sales-page guidance and request-local links.""" + +import asyncio +import re +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from iac_code.agent.system_prompt import build_system_prompt +from iac_code.pipeline.engine.context import PipelineContext +from iac_code.pipeline.engine.loader import load_pipeline_dir +from iac_code.pipeline.engine.pipeline_runner import PipelineRunner +from iac_code.pipeline.engine.step_executor import StepExecutor +from iac_code.pipeline.selling_solution_first.hooks.solution_planning_and_selection import build_prompt_context +from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential, use_aliyun_credential +from iac_code.services.session_storage import SessionStorage +from iac_code.tools.base import ToolRegistry +from iac_code.types.stream_events import ( + MessageEndEvent, + MessageStartEvent, + TextDeltaEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, +) + +PIPELINE = Path(__file__).resolve().parents[3] / "src/iac_code/pipeline/selling_solution_first" + + +@pytest.fixture(autouse=True) +def isolate_cloud_configuration(monkeypatch, tmp_path): + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr("iac_code.services.providers.aliyun.DEFAULT_ALIYUN_CLI_CONFIG_PATH", str(tmp_path / "cli.json")) + for name in ("ALIBABA_CLOUD_ACCESS_KEY_ID", "ALIBABA_CLOUD_ACCESS_KEY_SECRET", "ALIBABA_CLOUD_SECURITY_TOKEN"): + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def executor(tmp_path): + pipeline = load_pipeline_dir(PIPELINE) + return StepExecutor( + provider_manager=SimpleNamespace(), + base_tool_registry=ToolRegistry(), + pipeline=pipeline, + pipeline_dir=PIPELINE, + cwd=str(tmp_path), + ) + + +@pytest.mark.parametrize("value", [None, "", "0", "false", "off", "no", "unexpected"]) +def test_disabled_awareness_does_not_add_handoff_or_read_credentials(monkeypatch, value): + if value is None: + monkeypatch.delenv("IAC_CODE_ROS_AGENT_AWARENESS", raising=False) + else: + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", value) + + def unexpected_read(): + raise AssertionError("Disabled feature must not read a request credential") + + monkeypatch.setattr("iac_code.services.providers.aliyun.AliyunCredentials.load", unexpected_read) + assert build_prompt_context() == {"ros_agent_awareness": ""} + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE", " yes ", "on"]) +def test_enabled_awareness_uses_session_region(monkeypatch, value): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", value) + with use_aliyun_credential(AliyunCredential(region_id="cn-shanghai")): + supplement = build_prompt_context()["ros_agent_awareness"] + assert "[AI Assistant](https://ros.console.aliyun.com/cn-shanghai/chat)" in supplement + assert "{ai_assistant_destination}" not in supplement + assert len(supplement) <= 550, "Keep the optional guidance short" + assert re.search(r"[\u4e00-\u9fff]", supplement) is None + + +@pytest.mark.parametrize("region", ["", "../cn-beijing", "cn-beijing/chat?token=bad", "https://example.com"]) +def test_missing_or_invalid_session_region_uses_default(monkeypatch, region): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setenv("ALIBABA_CLOUD_REGION_ID", region) + with use_aliyun_credential(AliyunCredential(region_id=region)): + supplement = build_prompt_context()["ros_agent_awareness"] + assert f"https://ros.console.aliyun.com/{DEFAULT_REGION}/chat" in supplement + assert "example.com" not in supplement + assert "token=bad" not in supplement + + +def test_no_configured_credential_uses_default_region(monkeypatch): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setattr("iac_code.services.providers.aliyun.AliyunCredentials.load", lambda: None) + assert f"https://ros.console.aliyun.com/{DEFAULT_REGION}/chat" in build_prompt_context()["ros_agent_awareness"] + + +def test_configured_region_is_used_without_a_session_override(monkeypatch): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials.load", + lambda: AliyunCredential(region_id="cn-shenzhen"), + ) + assert "https://ros.console.aliyun.com/cn-shenzhen/chat" in build_prompt_context()["ros_agent_awareness"] + + +@pytest.mark.asyncio +async def test_pipeline_prompt_uses_each_concurrent_callers_console_region(executor, monkeypatch): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setenv("ALIBABA_CLOUD_REGION_ID", "cn-hangzhou") + step = executor._pipeline.steps[0] + context = PipelineContext(executor._pipeline.context_dependencies) + + async def prompt_for(region): + with use_aliyun_credential(AliyunCredential(region_id=region)): + await asyncio.sleep(0) + return executor._build_full_system_prompt(step, context) + + beijing, shanghai = await asyncio.gather(prompt_for("cn-beijing"), prompt_for("cn-shanghai")) + for prompt, own, other in [(beijing, "cn-beijing", "cn-shanghai"), (shanghai, "cn-shanghai", "cn-beijing")]: + assert f"https://ros.console.aliyun.com/{own}/chat" in prompt + assert f"https://ros.console.aliyun.com/{other}/chat" not in prompt + assert "https://ros.console.aliyun.com/cn-hangzhou/chat" not in prompt + base, skill_and_step = prompt.split("\n\n---\n\n", 1) + assert "ros_agent_awareness" not in base + assert "https://ros.console.aliyun.com/" not in base + assert f"https://ros.console.aliyun.com/{own}/chat" in skill_and_step + assert "ROS console override" in skill_and_step + assert "ros_agent_awareness" not in prompt + assert "override clarification/reselection" in prompt + assert "with `status: rejected`" in prompt + + +def test_handoff_is_limited_to_planning_and_leaves_normal_prompts_alone(executor, monkeypatch, tmp_path): + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setenv("ALIBABA_CLOUD_REGION_ID", "cn-beijing") + context = PipelineContext(executor._pipeline.context_dependencies) + prompts = [executor._build_full_system_prompt(step, context) for step in executor._pipeline.steps] + assert "ROS console override" in prompts[0] + assert prompts[0].index("ROS console override") > prompts[0].index("## Pipeline 交接约束") + assert all("ROS console override" not in prompt for prompt in prompts[1:]) + assert "ROS console override" not in build_system_prompt(cwd=str(tmp_path)) + assert "Keep deployment architecture/cost tradeoffs here" in prompts[0] + assert "do not promise discounts or session transfer" in prompts[0] + + +def test_planning_keeps_existing_clarification_when_awareness_is_off(executor, monkeypatch): + monkeypatch.delenv("IAC_CODE_ROS_AGENT_AWARENESS", raising=False) + context = PipelineContext(executor._pipeline.context_dependencies) + prompt = executor._build_full_system_prompt(executor._pipeline.steps[0], context) + assert "ros_agent_awareness" not in prompt + assert "ROS console override" not in prompt + assert "https://ros.console.aliyun.com/" not in prompt + assert "对于非部署/非云资源输入,应通过 `ask_user_question`" in prompt + rendered_skill = prompt.split("\n\n---\n\n", 1)[1].rsplit("\n\n---\n\n", 1)[0] + original_skill = executor._resolve_skill_prompt(executor._pipeline.steps[0].skill) + assert rendered_skill == original_skill.replace("{ros_agent_awareness}", "") + + +@pytest.mark.asyncio +async def test_guidance_is_visible_and_finishes_the_real_pipeline_without_candidates(tmp_path, monkeypatch): + """Script the model response; exercise actual tool execution and early exit.""" + monkeypatch.setenv("IAC_CODE_ROS_AGENT_AWARENESS", "true") + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(tmp_path / "backup")) + monkeypatch.setenv("ALIBABA_CLOUD_REGION_ID", "cn-hangzhou") + guidance = "请前往 [AI 助手](https://ros.console.aliyun.com/cn-beijing/chat) 咨询按量付费。" + + class Model: + def get_model_name(self): + return "offline-ros-handoff" + + async def stream(self, messages, system, tools=None): + assert "ROS console override" in system + assert "https://ros.console.aliyun.com/cn-beijing/chat" in system + yield MessageStartEvent(message_id="consultation") + yield TextDeltaEvent(text=guidance) + yield ToolUseStartEvent(tool_use_id="handoff", name="complete_step") + yield ToolUseEndEvent( + tool_use_id="handoff", + name="complete_step", + input={"conclusion": {"status": "rejected", "rejection_reason": guidance}}, + ) + yield MessageEndEvent(stop_reason="tool_use", usage=Usage()) + + flow = PipelineRunner( + pipeline_dir=PIPELINE, + provider_manager=Model(), + base_tool_registry=ToolRegistry(), + session_storage=SessionStorage(tmp_path / "projects"), + session_id="consultation-handoff", + cwd=str(tmp_path), + ) + with use_aliyun_credential(AliyunCredential(region_id="cn-beijing")): + events = [event async for event in flow.run("按量付费怎么计费?")] + assert "".join(event.text for event in events if isinstance(event, TextDeltaEvent)) == guidance + tools = [event.name for event in events if isinstance(event, ToolUseStartEvent)] + assert tools == ["complete_step"] + conclusion = flow.context.get_conclusion("solution_selection") + assert conclusion["status"] == "rejected" + assert conclusion["continue_pipeline"] is False + assert conclusion["rejection_reason"] == guidance + assert flow.context.get_conclusion("selected_plan") is None diff --git a/tests/pipeline/selling_solution_first/test_show_architecture_plan_tool.py b/tests/pipeline/selling_solution_first/test_show_architecture_plan_tool.py index 937710c85..a65bf57cb 100644 --- a/tests/pipeline/selling_solution_first/test_show_architecture_plan_tool.py +++ b/tests/pipeline/selling_solution_first/test_show_architecture_plan_tool.py @@ -40,7 +40,7 @@ def _tool_input(**overrides): "purpose": "应用计算", "quantity": 2, "recommended_spec": "2 vCPU / 4 GiB", - "rough_monthly_cost": "¥200~¥400/月", + "rough_cost": "¥200~¥400/月", "lifecycle": "create", } ], @@ -69,7 +69,7 @@ async def _run(tool_input) -> tuple[object, list]: { "candidate_name": "方案A:经典三层", "summary": "经典三层架构", - "total_monthly_cost": "¥200~¥400/月", + "total_cost": "¥200~¥400/月", "key_tradeoff": "组件较多", } ] @@ -110,7 +110,7 @@ def test_outline_schema_is_small_and_detail_owns_the_graph(self): assert outline_schema["required"] == ["candidates"] outline = outline_schema["properties"]["candidates"]["items"] - assert outline["required"] == ["candidate_name", "summary", "total_monthly_cost", "key_tradeoff"] + assert outline["required"] == ["candidate_name", "summary", "total_cost", "key_tradeoff"] assert "nodes" not in outline["properties"] graph = detail_schema["properties"]["topology_graph"] assert graph["required"] == ["nodes", "edges"] @@ -126,13 +126,13 @@ async def test_outline_call_emits_one_refining_card_per_candidate(self): { "candidate_name": "轻量方案", "summary": "单机", - "total_monthly_cost": "¥100~¥200/月", + "total_cost": "¥100~¥200/月", "key_tradeoff": "单点", }, { "candidate_name": "高可用方案", "summary": "双机", - "total_monthly_cost": "¥300~¥500/月", + "total_cost": "¥300~¥500/月", "key_tradeoff": "成本较高", }, ] @@ -155,7 +155,7 @@ async def test_identical_outline_batch_is_idempotent_and_emits_no_duplicate_card { "candidate_name": "轻量方案", "summary": "单机", - "total_monthly_cost": "¥100~¥200/月", + "total_cost": "¥100~¥200/月", "key_tradeoff": "单点", } ] @@ -195,7 +195,7 @@ async def test_changed_outline_batch_still_starts_a_new_candidate_set(self): { "candidate_name": "原方案", "summary": "单机", - "total_monthly_cost": "¥100/月", + "total_cost": "¥100/月", "key_tradeoff": "单点", } ] @@ -213,13 +213,13 @@ async def test_changed_outline_batch_still_starts_a_new_candidate_set(self): { "candidate_name": "原方案", "summary": "单机", - "total_monthly_cost": "¥100/月", + "total_cost": "¥100/月", "key_tradeoff": "单点", }, { "candidate_name": "新增方案", "summary": "轻量服务器", - "total_monthly_cost": "¥200/月", + "total_cost": "¥200/月", "key_tradeoff": "规格受限", }, ] @@ -241,7 +241,7 @@ async def test_outline_accepts_the_supported_batch_sizes(self, count): { "candidate_name": f"方案 {index}", "summary": f"摘要 {index}", - "total_monthly_cost": f"¥{index + 1}00/月", + "total_cost": f"¥{index + 1}00/月", "key_tradeoff": f"取舍 {index}", } for index in range(count) @@ -263,13 +263,13 @@ async def test_outline_accepts_the_supported_batch_sizes(self, count): { "candidate_name": "重复", "summary": "a", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "x", }, { "candidate_name": "重复", "summary": "b", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "y", }, ], @@ -277,7 +277,7 @@ async def test_outline_accepts_the_supported_batch_sizes(self, count): { "candidate_name": "方案", "summary": " ", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "x", } ], @@ -285,7 +285,7 @@ async def test_outline_accepts_the_supported_batch_sizes(self, count): { "candidate_name": f"方案 {index}", "summary": "摘要", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "取舍", } for index in range(4) @@ -325,13 +325,13 @@ async def test_detail_must_follow_current_batch_index_and_name(self): { "candidate_name": "方案 A", "summary": "A", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "A 取舍", }, { "candidate_name": "方案 B", "summary": "B", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "B 取舍", }, ] @@ -365,7 +365,7 @@ async def test_new_outline_batch_invalidates_old_successful_details(self): { "candidate_name": "旧方案", "summary": "旧", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "旧取舍", } ] @@ -390,7 +390,7 @@ async def test_new_outline_batch_invalidates_old_successful_details(self): { "candidate_name": "新方案", "summary": "新", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "新取舍", } ] @@ -423,13 +423,13 @@ async def test_restored_partial_batch_continues_with_only_the_first_missing_cand { "candidate_name": "方案 A", "summary": "A", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "A 取舍", }, { "candidate_name": "方案 B", "summary": "B", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "B 取舍", }, ] @@ -471,7 +471,7 @@ async def test_detail_from_an_explicit_old_batch_does_not_satisfy_the_new_batch( { "candidate_name": "同名方案", "summary": "旧摘要", - "total_monthly_cost": "¥1/月", + "total_cost": "¥1/月", "key_tradeoff": "旧取舍", } ] @@ -488,7 +488,7 @@ async def test_detail_from_an_explicit_old_batch_does_not_satisfy_the_new_batch( { "candidate_name": "同名方案", "summary": "新摘要", - "total_monthly_cost": "¥2/月", + "total_cost": "¥2/月", "key_tradeoff": "新取舍", } ] @@ -854,7 +854,7 @@ async def test_missing_event_queue_still_succeeds(self): { "candidate_name": "方案A:经典三层", "summary": "经典三层架构", - "total_monthly_cost": "¥200~¥400/月", + "total_cost": "¥200~¥400/月", "key_tradeoff": "组件较多", } ] @@ -877,7 +877,7 @@ async def test_same_outline_details_can_be_corrected_after_completion_guard_reje from iac_code.pipeline.engine.complete_step_tool import CompletionEnrichmentError from iac_code.pipeline.selling_solution_first.hooks.solution_planning_and_selection import enrich_completion_input - outline = {'candidate_name': '方案 A', 'summary': 'A', 'total_monthly_cost': '¥1/月', 'key_tradeoff': 'A'} + outline = {'candidate_name': '方案 A', 'summary': 'A', 'total_cost': '¥1/月', 'key_tradeoff': 'A'} detail = _tool_input(candidate_name='方案 A') records = [ {'tool_name': 'show_architecture_plan', 'input': {'candidates': [outline]}, 'is_error': False, @@ -902,7 +902,7 @@ def complete(): @pytest.mark.asyncio @pytest.mark.parametrize(('index', 'name'), [(1, '方案 A'), (True, '方案 A'), (-1, '方案 A'), (0, '旧方案')]) async def test_detail_correction_still_requires_actual_outline_index_and_name(index, name): - outline = {'candidate_name': '方案 A', 'summary': 'A', 'total_monthly_cost': '¥1/月', 'key_tradeoff': 'A'} + outline = {'candidate_name': '方案 A', 'summary': 'A', 'total_cost': '¥1/月', 'key_tradeoff': 'A'} state = {'tool_result_records': [ {'tool_name': 'show_architecture_plan', 'input': {'candidates': [outline]}, 'is_error': False, 'sequence': 1}, {'tool_name': 'show_candidate_detail', 'input': _tool_input(candidate_name='方案 A'), diff --git a/tests/pipeline/selling_solution_first/test_solution_planning_step.py b/tests/pipeline/selling_solution_first/test_solution_planning_step.py index 59f7cc873..503c47b00 100644 --- a/tests/pipeline/selling_solution_first/test_solution_planning_step.py +++ b/tests/pipeline/selling_solution_first/test_solution_planning_step.py @@ -45,14 +45,14 @@ def test_optional_candidate_resources_never_erase_explicit_user_or_non_create_re "name": "方案A:单机经济型", "summary": "单台 ECS 自建 Nginx + 本地 MySQL", "output_path": "templates/1-single-ecs.yml", - "rough_cost": {"currency": "CNY", "monthly_range": "¥120 - ¥180", "confidence": "medium"}, + "rough_cost": {"currency": "CNY", "cost_range": "¥120 - ¥180", "confidence": "medium"}, }, { "candidate_id": "cand-ha", "name": "方案B:高可用三层", "summary": "SLB + 2 台 ECS + RDS 高可用版", "output_path": "templates/2-high-availability-slb.yml", - "rough_cost": {"currency": "CNY", "monthly_range": "¥1,100 - ¥1,500", "confidence": "medium"}, + "rough_cost": {"currency": "CNY", "cost_range": "¥1,100 - ¥1,500", "confidence": "medium"}, }, ] OPTIONS = [ @@ -794,7 +794,7 @@ def test_every_candidate_carries_inventory_graph_and_rough_cost(self, raw_step): rough_cost = candidate["properties"]["rough_cost"] assert set(rough_cost["required"]) == { "currency", - "monthly_range", + "cost_range", "items", "assumptions", "exclusions", @@ -859,7 +859,7 @@ def test_skill_uses_one_candidate_coordinate_for_both_display_tools(self, skill_ assert "`options[i].candidate_index == i`" in skill_text assert "`candidate_id`、`output_path`" in skill_text assert "由 Python" in skill_text - assert "`rough_cost.monthly_range`" in skill_text + assert "`rough_cost.cost_range`" in skill_text # decision_notes 现在是完整的说服力字段(见「方案说服力」),仍要支撑 Python 生成紧凑 options。 assert "Python 生成紧凑 options" in skill_text diff --git a/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py b/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py index 967ccdb3d..dcffebec8 100644 --- a/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py +++ b/tests/pipeline_e2e/test_selling_solution_first_run_scenarios.py @@ -55,6 +55,19 @@ def runner() -> ModuleType: return _runner_module() +@pytest.fixture +def repl_poll_clock(runner, monkeypatch): + # These tests check polling decisions, not filesystem speed. Advance time + # only at sleeps so a busy Windows runner still performs the first read. + now = [0.0] + + def sleep(seconds): + now[0] += seconds + + monkeypatch.setattr(runner, 'time', SimpleNamespace(monotonic=lambda: now[0], sleep=sleep)) + return now + + @pytest.mark.parametrize(('depends_on_old', 'expected_count'), [(False, 0), (True, 1)]) def test_cleanup_dependency_probe_uses_accepted_stacks_and_exports_no_identity( runner, tmp_path, monkeypatch, capsys, depends_on_old, expected_count, @@ -211,7 +224,9 @@ def send(self, text, **kwargs): @pytest.mark.parametrize('terminal', [None, 'pipeline_failed', 'pipeline_completed']) -def test_multimodal_wait_without_native_input_fails_without_sending_image(runner, tmp_path, monkeypatch, terminal): +def test_multimodal_wait_without_native_input_fails_without_sending_image( + runner, tmp_path, monkeypatch, terminal, repl_poll_clock, +): pipeline = tmp_path / 'projects/p/s/pipeline' pipeline.mkdir(parents=True) if terminal: @@ -227,6 +242,9 @@ def test_multimodal_wait_without_native_input_fails_without_sending_image(runner runner._repl_wait_multimodal_confirmation(runtime, pty, primary_image_key='ask-first-answer', phase='initial') if not terminal: assert runtime.checks['REPL display user_input_required occurrence 1 observed'] is False + assert repl_poll_clock[0] == 0.1 + else: + assert repl_poll_clock[0] == 0.0 def test_multimodal_confirmation_ignores_confirmation_before_latest_selection(runner, tmp_path, monkeypatch): @@ -254,7 +272,7 @@ def drain(): assert pty.events[-1]['occurrence'] == 2 -def test_image_question_does_not_resend_while_original_question_is_unacknowledged(runner, tmp_path): +def test_image_question_does_not_resend_while_original_question_is_unacknowledged(runner, tmp_path, repl_poll_clock): meta = tmp_path / 'projects/p/s/pipeline/meta.yaml' meta.parent.mkdir(parents=True) meta.write_text(yaml.safe_dump({'current_step': runner.NEW_STEPS[1], 'execution': { @@ -2813,7 +2831,10 @@ def drain_output(self) -> None: @pytest.mark.parametrize("allow_free_text", [True, False]) def test_repl_running_step2_answers_native_question_before_template_fault( runner: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, allow_free_text: bool, + repl_poll_clock, ) -> None: + # Verify question/tool ordering independently of disk latency on loaded CI workers. + # The separate deadline test below explicitly advances the polling clock. directory = tmp_path / "projects/project/session/pipeline" transcript = directory / "transcripts/step2/session.jsonl" transcript.parent.mkdir(parents=True) diff --git a/tests/scripts/test_a2a_vpc_smoke.py b/tests/scripts/test_a2a_vpc_smoke.py index 28a46bb73..f568e64ca 100644 --- a/tests/scripts/test_a2a_vpc_smoke.py +++ b/tests/scripts/test_a2a_vpc_smoke.py @@ -236,6 +236,7 @@ def test_normal_turn_without_final_metadata_requires_natural_completion_proof(mo @pytest.mark.parametrize('cli_text, passed', [ ('{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}', True), ('```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```', True), + ('Template:\n```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```', False), ('VPC permission accepted', False), ('{"kind":"permission_ack","target":"vpc.json"}', False), ]) def test_finished_owner_with_stale_permission_requires_template_not_receipt(monkeypatch, tmp_path, cli_text, passed): @@ -253,6 +254,33 @@ def cli(prompt, **options): assert result.stdout == (cli_text if passed else '') +@pytest.mark.parametrize('text, passed', [ + ('Template:\n```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```\nDone.', True), + ('Template:\n```\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```', True), + ('Template: {"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}', False), + ('Template:\n```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}\n```', False), + ('Template:\n```json\n{"Resources":{"Subnet":{"Type":"ALIYUN::ECS::VSwitch"}}}\n```', False), + ('Receipt:\n```json\n{"kind":"permission_ack","target":"vpc.json"}\n```', False), + ('Envelope:\n```json\n{"result":{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}}\n```', False), +]) +def test_resolved_permission_accepts_complete_fenced_final_template(monkeypatch, tmp_path, text, passed): + task = _completed_task(text) + task['status']['state'] = 'TASK_STATE_INPUT_REQUIRED' + task['metadata'] = {'iac_code': {'input': None, 'pendingPermissions': []}} + calls, _probes, _clock = _task_flow(monkeypatch, [_permission_task(tmp_path), task]) + monkeypatch.setattr(smoke, '_normal_turn_completed', lambda *args, **kwargs: True) + result = smoke.run_a2a_client_call('JSON only', cwd=str(tmp_path)) + assert (result.returncode == 0) is passed and len(calls) == 2 + assert result.stdout == (text if passed else '') + + +def test_fenced_template_is_not_extracted_while_other_permissions_remain(): + text = 'Template:\n```json\n{"Resources":{"VPC":{"Type":"ALIYUN::ECS::VPC"}}}\n```' + task = _completed_task(text) + task['metadata'] = {'iac_code': {'input': None, 'pendingPermissions': [{'toolUseId': 'other-tool'}]}} + assert smoke._normal_turn_output(task, text, permission_submitted=True) == '' + + @pytest.mark.parametrize('updates, completed', [ ({}, True), ({'taskId': 'other-task'}, False), ({'contextId': 'other-context'}, False), ({'phase': 'running'}, False), ({'terminationReason': 'explicit'}, False), ({'releaseReady': False}, False), diff --git a/tests/scripts/test_ci_run_e2e.py b/tests/scripts/test_ci_run_e2e.py index 49270a7cc..2d5daaa2c 100644 --- a/tests/scripts/test_ci_run_e2e.py +++ b/tests/scripts/test_ci_run_e2e.py @@ -166,9 +166,9 @@ def test_default_selection_is_allowlisted_and_credential_free() -> None: def test_catalog_includes_headless_surfaces_and_excludes_browser_desktop() -> None: full = run_e2e.select_cases(run_e2e.parse_args(["--suite", "full", "--list"])) live = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live", "--list"])) - assert len(full) == 64 - assert len(live) == 121 - assert len(run_e2e.CASES) == 185 + assert len(full) == 66 + assert len(live) == 133 + assert len(run_e2e.CASES) == 199 agui = run_e2e.select_cases(run_e2e.parse_args(["--suite", "live-agui", "--list"])) assert {case.name for case in agui} == {"agui-selector-" + name for name in run_e2e.AGUI_SCENARIOS} assert len(agui) == 6 @@ -231,6 +231,29 @@ def test_live_requires_complete_credential_source_and_write_opt_in(tmp_path: Pat assert len(run_e2e.select_cases(args)) == len(run_e2e.LIVE_CASES) +def test_ros_awareness_cases_are_live_llm_only_and_keep_all_scenarios(tmp_path: Path) -> None: + for name in (".credentials.yml", "settings.yml"): + (tmp_path / name).write_text("fixture", encoding="utf-8") + cases = [case for case in run_e2e.LIVE_CASES if case.live_runner == "ros_awareness"] + assert {case.args[1] for case in cases} == set(run_e2e.ROS_AWARENESS_SCENARIOS) + for case in cases: + args = run_e2e.parse_args(["--case", case.name, "--credential-source-dir", str(tmp_path)]) + assert run_e2e.select_cases(args) == [case] + assert case.group == "readonly" and not case.cloud_write + assert run_e2e._live_cleanup_status(case, None) == "not-needed" + + +def test_ros_awareness_preserves_natural_and_explicit_billing_questions() -> None: + scenarios = run_e2e.ROS_AWARENESS_SCENARIOS + assert scenarios["billing"].prompt == "按量付费的 ECS 是如何计费的?欠费后怎么办?" + assert scenarios["billing-explicit"].prompt == ( + "我只咨询已有 ECS 的按量账单,不打算部署或创建资源。按量付费如何计费,欠费后怎么办?" + ) + assert scenarios["billing"].handoff and scenarios["billing-explicit"].handoff + case_names = {case.name for case in run_e2e.LIVE_CASES} + assert {"ros-awareness-billing", "ros-awareness-billing-explicit"} <= case_names + + def test_live_accepts_llm_only_source_with_cloud_helper(tmp_path: Path) -> None: for name in (".credentials.yml", "settings.yml"): (tmp_path / name).write_text("fixture", encoding="utf-8") @@ -966,8 +989,9 @@ def test_live_adapter_uses_isolated_credentials_and_sanitized_result( assert len(scenarios[0].name.removeprefix("scenario-")) == 32 -def test_smoke_adapter_has_model_config_but_no_cloud_credentials( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +@pytest.mark.parametrize("runner", ["smoke", "ros_awareness", "promotional_offer_followup"]) +def test_llm_only_adapter_has_model_config_but_no_cloud_credentials( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, runner: str ) -> None: script = tmp_path / "smoke.py" script.write_text( @@ -981,6 +1005,8 @@ def test_smoke_adapter_has_model_config_but_no_cloud_credentials( "assert Path(os.environ['HOME']).is_relative_to(run_dir)\n" "assert Path(os.environ['USERPROFILE']).is_relative_to(run_dir)\n" "assert '--allow-real-cloud' not in sys.argv\n" + "assert ('--allow-real-llm' in sys.argv) == " + "(sys.argv[sys.argv.index('--runner') + 1] in {'ros_awareness', 'promotional_offer_followup'})\n" "(run_dir / 'summary.json').write_text(" "json.dumps({'passed': True, 'checks': {'ok': True}}), encoding='utf-8')\n", encoding="utf-8", @@ -991,9 +1017,11 @@ def test_smoke_adapter_has_model_config_but_no_cloud_credentials( (source / ".cloud-credentials.yml").write_text("cloud-fixture", encoding="utf-8") (source / "settings.yml").write_text('{"activeProvider": "dashscope"}', encoding="utf-8") monkeypatch.setattr(run_e2e, "REPO_ROOT", tmp_path) - case = run_e2e.Case("smoke-vpc-fixture", "smoke.py", (), 5, "live", live_runner="smoke") + case = run_e2e.Case("llm-only-fixture", "smoke.py", ("--runner", runner), 5, "live", live_runner=runner) + helper = tmp_path / "must-not-run.py" + helper.write_text("raise AssertionError('LLM-only tests must not request cloud credentials')", encoding="utf-8") - result = run_e2e.run_case(case, tmp_path / "report", source) + result = run_e2e.run_case(case, tmp_path / "report", source, cloud_credential_helper=helper) assert result["status"] == "passed" assert result["cleanupStatus"] == "not-needed" diff --git a/tests/scripts/test_coupon_budget_a2a.py b/tests/scripts/test_coupon_budget_a2a.py new file mode 100644 index 000000000..8df24c320 --- /dev/null +++ b/tests/scripts/test_coupon_budget_a2a.py @@ -0,0 +1,16 @@ +"""Acceptance distinguishes real period mistakes from free resources and prose.""" + +import pytest + +from scripts.ci.run_coupon_budget_a2a import hourly_costs_match + + +@pytest.mark.parametrize(("outlines", "details", "expected"), [ + (["¥30~¥60(本次 8 小时使用合计,非月费)"], ["¥10/8 小时", "¥0(VPC 本身免费)"], True), + (["¥24~¥40/8 小时"], ["¥3~¥5/小时", "¥0", "0", "免费"], True), + (["¥300/月(小时费用待确认)"], ["¥3/小时"], False), + (["¥3/小时"], ["¥300/月"], False), + ([], ["¥3/小时"], False), +]) +def test_hourly_estimate_acceptance(outlines, details, expected): + assert hourly_costs_match(outlines, details) is expected diff --git a/tests/scripts/test_coupon_budget_mock_a2a.py b/tests/scripts/test_coupon_budget_mock_a2a.py new file mode 100644 index 000000000..6e2b1d719 --- /dev/null +++ b/tests/scripts/test_coupon_budget_mock_a2a.py @@ -0,0 +1,78 @@ +from types import SimpleNamespace + +import pytest + +from scripts.ci import run_coupon_budget_mock_a2a as runner + + +@pytest.mark.parametrize("text, content_type, passed", [ + ("", "text/event-stream", False), + (" ", "text/event-stream", False), + ("|本次计算费可抵扣金额|0元|\n|计算部分需补现金|0元|", "text/event-stream", False), + ("|本次计算费可抵扣金额|待确认|\n|计算部分需补现金|待确认|", "application/json", False), + ("|本次计算费可抵扣金额|待确认|\n|计算部分需补现金|待确认|", "text/event-stream", True), +]) +def test_coupon_run_requires_the_original_answer_without_a_followup( + monkeypatch, tmp_path, text, content_type, passed, +): + config = tmp_path / "config" + config.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config)) + calls = [] + ledger = [] + lifecycle = [] + + class Server: + def __init__(self, **_kwargs): + pass + + def start(self): + lifecycle.append("started") + + def terminate(self): + lifecycle.append("terminated") + + class Capture: + def __init__(self, _path): + self.env = {} + + def start(self): + return self + + def stop(self): + return [{"attributes": {"gen_ai.request.model": "offline-test-model"}}] + + def stream(**options): + calls.append(options) + if options["prompt"] == runner.QUESTION: + ledger.append({"case": "query_denied", "action": "DescribeCoupon", "mocked": True}) + return SimpleNamespace( + context_id="context-one", task_id="task-one", normal_handoff_ready=False, + last_status_state="TASK_STATE_INPUT_REQUIRED", response_content_type=content_type, + ) + + monkeypatch.setattr(runner, "ManagedServer", Server) + monkeypatch.setattr(runner, "ObserveCapture", Capture) + monkeypatch.setattr(runner, "_free_port", lambda _host: 12345) + monkeypatch.setattr(runner, "_write_server_config", lambda *args, **kwargs: tmp_path / "server.json") + monkeypatch.setattr(runner, "wait_for_server", lambda *args, **kwargs: None) + monkeypatch.setattr(runner, "stream_message", stream) + monkeypatch.setattr(runner, "recorded_calls", lambda _path: ledger.copy()) + monkeypatch.setattr(runner, "tool_inputs", lambda _path: [ + {"id": "guide-read", "name": "read_file", "input": {"path": "references/coupons.md"}}, + ]) + monkeypatch.setattr(runner, "visible_pipeline_text", lambda _path: text) + monkeypatch.setattr(runner, "pipeline_facts", lambda _path: ({"status": "awaiting_selection"}, [])) + + result = runner.run(SimpleNamespace( + run_dir=tmp_path, provider="offline-test-provider", model="offline-test-model", + scenario=["query_denied"], timeout=10, + )) + + assert [call["prompt"] for call in calls] == [runner.SETUP, runner.QUESTION] + assert calls[1]["context_id"] == "context-one" and calls[1]["task_id"] == "task-one" + assert calls[1]["timeout"] == 10 + assert result["status"] == ("passed" if passed else "failed") + assert result["cases"][0]["passed"] is passed + assert result["cases"][0]["checks"]["visible answer returned for the original question"] is bool(text.strip()) + assert lifecycle == ["started", "terminated"] diff --git a/tests/scripts/test_coupon_fixtures.py b/tests/scripts/test_coupon_fixtures.py new file mode 100644 index 000000000..d42e5302b --- /dev/null +++ b/tests/scripts/test_coupon_fixtures.py @@ -0,0 +1,80 @@ +"""Keep mock identity/pagination and answer acceptance from hiding model errors.""" + +import pytest + +from scripts.ci.coupon_fixtures import coupon, response +from scripts.ci.run_coupon_budget_mock_a2a import amount_matches, cash_cell, evaluate_answer, has_assistant_reference + + +@pytest.mark.parametrize(("text", "expected"), [ + ("[AI Assistant](https://ros.console.aliyun.com/cn-beijing/chat)", True), + ("https://ROS.CONSOLE.ALIYUN.COM/cn-hangzhou/chat", True), + ("https://example.com/ros.console.aliyun.com/cn-beijing/chat", False), + ("https://ros.console.aliyun.com.example.com/cn-beijing/chat", False), + ("https://ros.console.aliyun.com@example.com/cn-beijing/chat", False), + ("https://example.com/?next=https%3A%2F%2Fros.console.aliyun.com%2Fchat", False), + ("https://[invalid/path", False), + ("No link", False), +]) +def test_assistant_reference_uses_the_exact_url_host(text, expected): + assert has_assistant_reference(text) is expected + + +def test_an_old_coupon_id_cannot_observe_a_new_fixture(): + old = coupon("applicable")["CouponId"] + result = response("balance_60", "DescribeCouponItemList", {"CouponId": old}) + assert result["Data"] == [] + + +def test_pagination_does_not_repeat_the_coupon_on_later_pages(): + first = response("applicable", "DescribeCoupon", {"CurrentPage": 1, "PageSize": 1}) + second = response("applicable", "DescribeCoupon", {"CurrentPage": 2, "PageSize": 1}) + assert len(first["Data"]) == 1 + assert second["Data"] == [] + assert first["TotalCount"] == second["TotalCount"] == 1 + + +def test_answer_acceptance_ignores_the_face_value_and_estimate_rows(): + answer = "| 券面值 | 300 元 |\n| GPU计算费 | 240 元 |\n| 计算部分需补现金 | **180.00 元** |" + assert cash_cell(answer) == "**180.00 元**" + assert amount_matches(cash_cell(answer), 180) + assert not amount_matches(cash_cell(answer), 300) + + +def test_horizontal_answer_table_is_not_mistaken_for_a_missing_cash_value(): + answer = "| GPU计算费 | 本次计算费可抵扣金额 | 计算部分需补现金 |\n|---|---|---|\n|240 元|240 元|0 元|" + assert cash_cell(answer) == "0 元" + assert all(evaluate_answer("applicable", answer).values()) + + +def test_an_explanation_mentioning_cash_is_not_a_horizontal_header(): + answer = ( + "| 项目 | 结果 |\n|---|---|\n" + "| 券的可用余额 | 300 元(满减券,非现金抵扣余额) |\n" + "| 本次计算费可抵扣金额 | 0 元(未达门槛) |\n" + "| 计算部分需补现金 | 240 元 |" + ) + assert cash_cell(answer) == "240 元" + assert all(evaluate_answer("threshold", answer).values()) + + +def test_a_known_zero_deduction_is_distinct_from_uncertain_coupon_eligibility(): + estimate = "|本次计算费可抵扣金额|¥0|\n|计算部分需补现金|待确认 → 预计 ¥240|" + uncertain = "|本次计算费可抵扣金额|待确认|\n|计算部分需补现金|待确认|" + assert all(evaluate_answer("gpu_excluded", estimate).values()) + assert not all(evaluate_answer("expired", uncertain).values()) + + +@pytest.mark.parametrize(("cell", "expected", "matches"), [ + ("¥0.00(仅计算费用,实际结算为准)", 0, True), + ("180 元", 180, True), + ("0 元(待确认项:无,判定明确)", 0, True), + ("0 元(待确认项:无,判定明确)", None, False), + ("0 元(待确认金额)", 0, False), + ("待确认(如果可用则 0 元)", 0, False), + ("待确认(如果可用则 0 元)", None, True), + ("¥0.00", None, False), + ("", None, False), +]) +def test_cash_acceptance_preserves_unknowns(cell, expected, matches): + assert amount_matches(cell, expected) is matches diff --git a/tests/scripts/test_promotional_offer_followup_a2a.py b/tests/scripts/test_promotional_offer_followup_a2a.py new file mode 100644 index 000000000..476bee4cb --- /dev/null +++ b/tests/scripts/test_promotional_offer_followup_a2a.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json + +from scripts.ci.run_promotional_offer_followup_a2a import DESTINATION, pipeline_facts, visible_pipeline_text + + +def test_visible_link_reassembles_wire_deltas_without_accepting_tool_or_thinking(tmp_path): + path = tmp_path / "turn.events.jsonl" + events = [ + {"eventType": "thinking_delta", "data": {"text": DESTINATION}}, + {"eventType": "tool_result", "data": {"input": {"rejection_reason": DESTINATION}}}, + {"eventType": "text_delta", "data": {"text": "[AI Assistant](https://ros"}}, + {"eventType": "text_delta", "data": {"text": ".console.aliyun.com/cn-hangzhou/chat)"}}, + ] + wire = [ + {"result": {"statusUpdate": {"metadata": {"iac_code": {"pipeline": event}}}}} + for event in events + ] + path.write_text("\n".join(json.dumps(event) for event in wire), encoding="utf-8") + assert visible_pipeline_text(path) == f"[AI Assistant]({DESTINATION})" + + +def test_pipeline_facts_use_accepted_conclusion_and_record_unselected_step_advance(tmp_path): + path = tmp_path / "turn.events.jsonl" + events = [ + {"eventType": "tool_use", "data": {"input": {"conclusion": {"status": "rejected"}}}}, + {"eventType": "tool_result", "data": {"normalizedConclusion": {"status": "selected"}}}, + {"eventType": "step_started", "step": {"id": "materialize_selected_candidate"}}, + ] + path.write_text("\n".join(json.dumps(event) for event in events), encoding="utf-8") + conclusion, steps = pipeline_facts(path) + assert conclusion == {"status": "selected"} + assert steps == ["materialize_selected_candidate"] diff --git a/tests/skills/bundled/test_iac_skill.py b/tests/skills/bundled/test_iac_skill.py index 617ea6830..1d82d9c0a 100644 --- a/tests/skills/bundled/test_iac_skill.py +++ b/tests/skills/bundled/test_iac_skill.py @@ -149,7 +149,8 @@ def test_ecs_reference_requires_vswitch(self): def test_cloud_product_catalogs_list_all_bundled_product_references(self): product_names = {path.name for path in (IAC_SKILL_ROOT / "references" / "cloud-products").glob("*.md")} - for skill_path in [IAC_SKILL_ROOT / "SKILL.md", *SELLING_IAC_SKILLS]: + solution_first_skills = Path("src/iac_code/pipeline/selling_solution_first/skills").glob("*/SKILL.md") + for skill_path in [IAC_SKILL_ROOT / "SKILL.md", *SELLING_IAC_SKILLS, *solution_first_skills]: content = skill_path.read_text(encoding="utf-8") missing = sorted(name for name in product_names if name not in content) assert not missing, f"{skill_path} is missing cloud product references: {missing}" diff --git a/tests/test_setup_packaging.py b/tests/test_setup_packaging.py index 4d0e8d1cf..d84fad430 100644 --- a/tests/test_setup_packaging.py +++ b/tests/test_setup_packaging.py @@ -4,6 +4,7 @@ import shutil from pathlib import Path +import pytest import setuptools try: @@ -12,6 +13,9 @@ import tomli as tomllib import iac_code +from iac_code.tools.base import ToolContext +from iac_code.tools.read_file import ReadFileTool +from iac_code.types.permissions import ToolPermissionContext PROJECT_ROOT = Path(__file__).resolve().parents[1] SOLUTION_FIRST_SKILLS = PROJECT_ROOT / "src/iac_code/pipeline/selling_solution_first/skills" @@ -60,7 +64,11 @@ def _assert_expanded_selling_references(package_root: Path, skill_names: tuple[s def _seed_solution_first_skill_files(package_root: Path) -> None: """Model build_py copying package data before setup materializes references.""" target_skills = package_root / "pipeline" / "selling_solution_first" / "skills" - for skill_name in ("iac-aliyun-deploying", "iac-aliyun-materialize-selected-candidate"): + for skill_name in ( + "iac-aliyun-solution-first", + "iac-aliyun-deploying", + "iac-aliyun-materialize-selected-candidate", + ): source = SOLUTION_FIRST_SKILLS / skill_name target = target_skills / skill_name target.mkdir(parents=True, exist_ok=True) @@ -87,11 +95,47 @@ def _assert_expanded_solution_first_resources(package_root: Path, setup_module) package_root, setup_module.SELLING_IAC_ALIYUN_SKILLS, ) - for references in (deploying / "references", materialize_references): + for skill_name in setup_module.SELLING_SOLUTION_FIRST_REFERENCE_SKILLS: + references = skills / skill_name / "references" assert references.is_dir() and not references.is_symlink() assert (references / "ros-template.md").is_file() assert (references / "cloud-products" / "ecs.md").is_file() assert (references / "solutions" / "iac-code-web.ros.yml").is_file() + assert (references / "cloud-products" / "swas.md").read_bytes() == ( + PROJECT_ROOT / "src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md" + ).read_bytes() + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("reference", "expected"), [ + ("cloud-products/swas.md", ["OriginPrice", "包月价格"]), + ("coupons.md", ["DescribeCoupon", "DescribeCouponItemList"]), +]) +async def test_installed_step_one_can_read_guides_from_an_unrelated_workspace( + tmp_path, monkeypatch, reference, expected, +): + setup_module = _load_setup_module(monkeypatch) + package_root = tmp_path / "installed" / "iac_code" + _seed_solution_first_skill_files(package_root) + setup_module._copy_selling_skill_references_to_package_root(package_root) + skill_root = package_root / "pipeline/selling_solution_first/skills/iac-aliyun-solution-first" + workspace = tmp_path / "workspace" + workspace.mkdir() + roots = [str(skill_root)] + tool_input = {"path": f"references/{reference}"} + tool = ReadFileTool() + + permission = await tool.check_permissions( + tool_input, + ToolPermissionContext(cwd=str(workspace), trusted_read_directories=roots, relative_read_directories=roots), + ) + assert permission.behavior == "allow" + result = await tool.execute( + tool_input=tool_input, + context=ToolContext(cwd=str(workspace), trusted_read_directories=roots, relative_read_directories=roots), + ) + assert not result.is_error + assert all(value in result.content for value in expected) def test_selling_skill_references_are_expanded_for_installed_artifacts(monkeypatch, tmp_path): diff --git a/tests/tools/cloud/aliyun/test_aliyun_api_doc.py b/tests/tools/cloud/aliyun/test_aliyun_api_doc.py index 3cf968d30..a34b3b1e8 100644 --- a/tests/tools/cloud/aliyun/test_aliyun_api_doc.py +++ b/tests/tools/cloud/aliyun/test_aliyun_api_doc.py @@ -651,7 +651,9 @@ async def test_doc_and_execute_share_public_identity_validation_before_openmeta( ( "temporarily_unavailable", "metadata_unavailable", - "Alibaba Cloud API metadata for Ecs/DescribeInstances is temporarily unavailable; try again later.", + "Alibaba Cloud API metadata for Ecs/DescribeInstances is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported.", ), ( "protocol_error", @@ -932,7 +934,9 @@ async def test_unknown_parameter_style_uses_canonical_contract_reason() -> None: ( None, "temporarily_unavailable", - "Alibaba Cloud API metadata for Ecs/Missing is temporarily unavailable; try again later.", + "Alibaba Cloud API metadata for Ecs/Missing is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported.", ), ( None, diff --git a/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py b/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py index 377d362e7..9f43cb294 100644 --- a/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py +++ b/tests/tools/cloud/aliyun/test_aliyun_api_permissions.py @@ -2160,7 +2160,9 @@ async def build(self, contract, tool_input): ), ( "metadata_unavailable", - "Alibaba Cloud API metadata for Ecs/DescribeInstances is temporarily unavailable; try again later.", + "Alibaba Cloud API metadata for Ecs/DescribeInstances is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported.", ), ( "invalid_or_missing_version", diff --git a/tests/tools/cloud/aliyun/test_openmeta.py b/tests/tools/cloud/aliyun/test_openmeta.py index 0717de5f8..99376fe85 100644 --- a/tests/tools/cloud/aliyun/test_openmeta.py +++ b/tests/tools/cloud/aliyun/test_openmeta.py @@ -1099,7 +1099,7 @@ async def test_api_request_identity_completes_official_payload_across_all_cache_ await disk_client.aclose() clock.advance(timedelta(days=8)) - stale_client = client(tmp_path, Remote([response(500), response(500)]), clock) + stale_client = client(tmp_path, Remote([response(500) for _ in range(6)]), clock) assert (await stale_client.get_product("Ecs")).value is not None stale = await stale_client.get_api("Ecs", "2014-05-26", "DescribeInstances") @@ -1249,7 +1249,7 @@ async def test_client_product_style_fallback_expires_with_products_cache_envelop [ response(200, load_fixture("products.json")), response(200, first_api), - refresh_response, + *([response(500) for _ in range(3)] if refresh_outcome == "unavailable" else [refresh_response]), response(200, second_api), response(404), ] @@ -1268,7 +1268,7 @@ async def test_client_product_style_fallback_expires_with_products_cache_envelop assert refreshed.error == "temporarily_unavailable" assert after_expiry.value is None assert after_expiry.error == "protocol_error" - assert len(remote.requests) == 5 + assert len(remote.requests) == (7 if refresh_outcome == "unavailable" else 5) assert remote.requests[-1].url.path.endswith("/Ecs/versions/2014-05-26/api-docs.json") await openmeta.aclose() @@ -1448,7 +1448,7 @@ async def test_stale_prefetched_api_docs_are_a_positive_fallback_for_exact_metad "components": describe_regions.pop("components"), "apis": {"DescribeRegions": describe_regions}, } - remote = Remote([response(200, api_docs_payload), response(503)]) + remote = Remote([response(200, api_docs_payload), *[response(503) for _ in range(3)]]) openmeta = client(tmp_path, remote, clock) openmeta.prefetch_api_docs("Ecs", ("2014-05-26",)) @@ -1459,7 +1459,7 @@ async def test_stale_prefetched_api_docs_are_a_positive_fallback_for_exact_metad assert result.value is not None and result.value.action == "DescribeRegions" assert result.source == "stale_cache" assert result.cache_status == "disk_stale" - assert len(remote.requests) == 2 + assert len(remote.requests) == 4 await openmeta.aclose() @@ -1614,7 +1614,7 @@ async def test_client_classifies_semantic_and_http_errors( tmp_path: Path, status: int, payload: object, error: str ) -> None: clock = FakeClock() - remote = Remote([response(status, payload)]) + remote = Remote([response(status, payload) for _ in range(3 if status in {429, 500} else 1)]) openmeta = client(tmp_path, remote, clock) result = await openmeta.get_api("Ecs", "2014-05-26", "DescribeInstances") @@ -1743,7 +1743,7 @@ async def test_client_rejects_a_third_safe_redirect(tmp_path: Path) -> None: @pytest.mark.asyncio async def test_client_classifies_timeout_as_temporarily_unavailable(tmp_path: Path) -> None: clock = FakeClock() - openmeta = client(tmp_path, Remote([httpx.ReadTimeout("timed out")]), clock) + openmeta = client(tmp_path, Remote([httpx.ReadTimeout("timed out") for _ in range(3)]), clock) result = await openmeta.get_api("Ecs", "2014-05-26", "DescribeInstances") @@ -1863,7 +1863,7 @@ async def test_disk_cleanup_limits_managed_cache_and_leaves_product_summary( async def test_client_fresh_stale_negative_and_corrupt_disk_cache_boundaries(tmp_path: Path) -> None: clock = FakeClock() payload = load_fixture("ecs_describe_instances.json") - remote = Remote([response(200, payload), response(500), response(404)]) + remote = Remote([response(200, payload), *[response(500) for _ in range(3)], response(404)]) openmeta = client(tmp_path, remote, clock) remote_result = await openmeta.get_api("Ecs", "2014-05-26", "DescribeInstances") @@ -1886,7 +1886,7 @@ async def test_client_fresh_stale_negative_and_corrupt_disk_cache_boundaries(tmp cache_file = tmp_path / "apis" / "Ecs" / "2014-05-26" / "DescribeInstances.json" cache_file.write_text('{"payload_sha256":"wrong"}', encoding="utf-8") - second = client(tmp_path, Remote([response(500)]), clock) + second = client(tmp_path, Remote([response(500) for _ in range(3)]), clock) assert (await second.get_api("Ecs", "2014-05-26", "DescribeInstances")).error == "temporarily_unavailable" await second.aclose() @@ -2037,7 +2037,7 @@ async def test_product_lookup_does_not_negative_cache_fresh_summary_miss_when_re } ) write_cache_envelope(tmp_path / "products.zh-cn.json", cached_products, clock) - remote = Remote([response(500, {"message": "temporary"}), response(200, refreshed_products)]) + remote = Remote([*[response(500, {"message": "temporary"}) for _ in range(3)], response(200, refreshed_products)]) openmeta = client(tmp_path, remote, clock) unavailable = await openmeta.get_product("AgentTeams") @@ -2047,7 +2047,7 @@ async def test_product_lookup_does_not_negative_cache_fresh_summary_miss_when_re assert unavailable.error == "temporarily_unavailable" assert recovered.value is not None assert recovered.value.product == "AgentTeams" - assert len(remote.requests) == 2 + assert len(remote.requests) == 4 await openmeta.aclose() diff --git a/tests/tools/cloud/aliyun/test_openmeta_retry.py b/tests/tools/cloud/aliyun/test_openmeta_retry.py new file mode 100644 index 000000000..822e92047 --- /dev/null +++ b/tests/tools/cloud/aliyun/test_openmeta_retry.py @@ -0,0 +1,225 @@ +"""Bounded recovery of transient public metadata failures, without cloud credentials.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +from functools import partial +from pathlib import Path +from types import SimpleNamespace + +import httpx +import pytest + +from iac_code.tools.cloud.aliyun import openmeta as module +from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi +from iac_code.tools.cloud.aliyun.contract_store import canonical_input_sha256 +from iac_code.tools.cloud.aliyun.openmeta import OpenMetaClient +from iac_code.tools.cloud.aliyun.retry_policy import RetryBudget, retry_delay +from iac_code.tools.cloud.aliyun.runtime import create_aliyun_runtime_services +from iac_code.types.permissions import InvocationBinding, ToolPermissionContext + + +@pytest.fixture(autouse=True) +def immediate_backoff(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "retry_delay", lambda *_args, **_kwargs: 0.0) + + +def api_response() -> httpx.Response: + fixture = Path(__file__).parent / "fixtures/openmeta/ecs_describe_instances.json" + return httpx.Response(200, json=json.loads(fixture.read_text(encoding="utf-8"))) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", + [429, 500, 502, 503, 504, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ConnectError, httpx.ReadError], +) +async def test_transient_metadata_failure_recovers_and_is_cached(tmp_path: Path, failure) -> None: + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + if isinstance(failure, int): + return httpx.Response(failure) + raise failure("temporary failure") + return api_response() + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + try: + recovered = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + cached = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert recovered.value is not None + assert cached.cache_status == "memory_fresh" + assert calls == 2 + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_exhausted_metadata_retries_do_not_negative_cache(tmp_path: Path) -> None: + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(503) if calls <= 3 else api_response() + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + try: + failed = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert failed.error == "temporarily_unavailable" + assert calls == 3 + recovered = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert recovered.value is not None + assert calls == 4 + finally: + await client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [400, 401, 403, 404, 501, 505]) +async def test_permanent_metadata_failure_does_not_retry(tmp_path: Path, status: int) -> None: + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(status) + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + try: + result = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert result.value is None + assert calls == 1 + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_metadata_deadline_cancels_inflight_read(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "_REQUEST_TIMEOUT_SECONDS", 0.03) + cancelled = asyncio.Event() + + async def handler(_request: httpx.Request) -> httpx.Response: + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + started = time.monotonic() + try: + result = await asyncio.wait_for(client.get_api("Ecs", "2014-05-26", "DescribeInstances"), timeout=1) + assert result.error == "temporarily_unavailable" + assert cancelled.is_set() + assert time.monotonic() - started < 0.5 + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_backoff_cannot_cross_metadata_deadline(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + elapsed = 0.0 + # Leave request startup independent of runner load, then simulate a response + # with only 10 ms left. Keep asyncio's scheduler on its real clock. + def clock() -> float: + return elapsed + + monkeypatch.setattr(module, "time", SimpleNamespace(monotonic=clock)) + monkeypatch.setattr(module, "RetryBudget", partial(RetryBudget, clock=clock)) + monkeypatch.setattr(module, "retry_delay", retry_delay) + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls, elapsed + calls += 1 + elapsed = module._REQUEST_TIMEOUT_SECONDS - 0.01 + return httpx.Response(503, headers={"Retry-After": "2"}) + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + started = time.monotonic() + try: + result = await client.get_api("Ecs", "2014-05-26", "DescribeInstances") + assert result.error == "temporarily_unavailable" + assert calls == 1 + assert time.monotonic() - started < 0.5 + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_concurrent_waiters_share_metadata_retries(tmp_path: Path) -> None: + calls = 0 + started = asyncio.Event() + release = asyncio.Event() + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + started.set() + await release.wait() + return httpx.Response(503) if calls < 3 else api_response() + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + tasks = [asyncio.create_task(client.get_api("Ecs", "2014-05-26", "DescribeInstances")) for _ in range(10)] + try: + await started.wait() + await asyncio.sleep(0) + release.set() + results = await asyncio.gather(*tasks) + assert all(result.value is not None for result in results) + assert calls == 3 + finally: + await client.aclose() + await asyncio.gather(*tasks, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_retry_diagnostics_exclude_query_and_exception_payload(tmp_path: Path, caplog) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("PRIVATE") + + client = OpenMetaClient(cache_dir=tmp_path, transport=httpx.MockTransport(handler)) + try: + with caplog.at_level(logging.WARNING, logger=module.__name__): + result = await client._request_json("https://api.aliyun.com/meta/v1/demo?token=PRIVATE", max_bytes=1024) + assert result[1] == "temporarily_unavailable" + assert "ConnectError" in caplog.text + assert "attempt=3" in caplog.text + assert "elapsed_ms=" in caplog.text + assert "/meta/v1/demo" in caplog.text + assert "PRIVATE" not in caplog.text + assert "token=" not in caplog.text + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_final_metadata_failure_tells_model_to_retry_without_using_credentials(tmp_path: Path) -> None: + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(503) + + runtime = create_aliyun_runtime_services(cache_dir=tmp_path, openmeta_transport=httpx.MockTransport(handler)) + runtime.credential_provider = lambda: pytest.fail("metadata failure must precede cloud credential access") + tool_input = {"product": "SWAS-OPEN", "action": "ListPlans", "region_id": "cn-hangzhou"} + binding = InvocationBinding("runtime", "session", "call", "aliyun_api", canonical_input_sha256(tool_input)) + try: + permission = await AliyunApi(services=runtime).check_permissions( + tool_input, + ToolPermissionContext(cwd=str(tmp_path), invocation_binding=binding, pipeline_mode=True), + ) + assert permission.behavior == "deny" + assert permission.message is not None + assert "Retry the same tool call once" in permission.message + assert calls == 3 + finally: + await runtime.aclose() diff --git a/tests/tools/cloud/aliyun/test_public_errors.py b/tests/tools/cloud/aliyun/test_public_errors.py index 68aecdab3..0aea9ef08 100644 --- a/tests/tools/cloud/aliyun/test_public_errors.py +++ b/tests/tools/cloud/aliyun/test_public_errors.py @@ -57,7 +57,9 @@ def test_aliyun_public_error_templates_are_directly_extractable(tmp_path: Path) "Verify the cloud resource state before retrying.", "Alibaba Cloud returned an unsupported response body for {operation}. " "Verify the cloud resource state before retrying.", - "Alibaba Cloud API metadata for {operation} is temporarily unavailable; try again later.", + "Alibaba Cloud API metadata for {operation} is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported.", "Alibaba Cloud endpoint cache could not be updated for {operation} in {region}. " "Check local configuration storage and retry.", "Alibaba Cloud API parameters are invalid for {operation}.", diff --git a/tests/web/test_diagram_preview.py b/tests/web/test_diagram_preview.py index 984ece221..ce3935e25 100644 --- a/tests/web/test_diagram_preview.py +++ b/tests/web/test_diagram_preview.py @@ -38,7 +38,7 @@ def test_diagram_preview_page_uses_saved_theme_renderer_and_token_mode(tmp_path, assert '' in response.text assert 'data-architecture-diagram-renderer="mermaid"' in response.text assert 'data-token-mode="true"' in response.text - assert "/static/js/diagram_preview.js?v=diagram-preview-v7" in response.text + assert "/static/js/diagram_preview.js?v=diagram-preview-v8" in response.text def test_diagram_preview_serves_the_shared_30_template_corpus(tmp_path, monkeypatch) -> None: diff --git a/tests/web/test_frontend_static.py b/tests/web/test_frontend_static.py index 220158f88..7d0e7bb7b 100644 --- a/tests/web/test_frontend_static.py +++ b/tests/web/test_frontend_static.py @@ -1932,7 +1932,7 @@ def test_static_asset_versions_reload_rename_api_changes() -> None: workspace_source = _source(WORKSPACE_JS) assert "/static/styles.css?v=web-repl-ui-324" in html - assert "/static/js/app.js?v=web-repl-ui-382" in html + assert "/static/js/app.js?v=web-repl-ui-383" in html # api.js 导出 WEB_EVENT_TYPES(EventSource 订阅白名单)与 openEventStream;新增 # pipeline.step.marker 订阅后必须 bump 其 import 版本位,否则回访浏览器加载「新 # app.js + 旧缓存 api.js」,EventSource 仍不监听该事件名,实时流水线主区照样空白。 @@ -1966,7 +1966,7 @@ def test_static_asset_versions_reload_rename_api_changes() -> None: # cloud-creds 面板(Task 5/6)重写后须 bump 全局版本位并给 workspace.js 加 per-file # 版本位,否则回访浏览器加载旧缓存 workspace.js,拿不到新的云凭证面板结构。 - assert "web-repl-ui-382" in index_html + assert "web-repl-ui-383" in index_html assert "web-repl-ui-333" not in index_html # events.js 新增实时 MCP/工具进度归并,必须 bump 版本避免旧 reducer 丢事件。 assert "./events.js?v=web-repl-ui-324" in app_source @@ -11724,7 +11724,7 @@ def test_session_updated_folds_current_session_into_sidebar_arrays() -> None: def test_index_html_cache_version_bumped() -> None: html = _source(INDEX_HTML) - assert "web-repl-ui-382" in html + assert "web-repl-ui-383" in html assert "web-repl-ui-343" not in html @@ -12231,7 +12231,7 @@ def test_output_panel_module_exists_and_wired() -> None: assert "getOutputs" in source app_source = _source(APP_JS) assert "createOutputController" in app_source - assert "output_panel.js?v=output-panel-v38" in app_source + assert "output_panel.js?v=output-panel-v39" in app_source def test_desktop_resource_stack_links_use_native_external_opener() -> None: @@ -12315,7 +12315,7 @@ def test_output_preview_and_highlight() -> None: assert "File no longer exists" in source assert "tok-" in source app_source = _source(APP_JS) - assert "output_panel.js?v=output-panel-v38" in app_source + assert "output_panel.js?v=output-panel-v39" in app_source assert "output_panel.js?v=output-panel-v23" not in app_source @@ -12346,8 +12346,8 @@ def test_output_panel_uses_codex_dark_theme() -> None: def test_output_panel_renders_architecture_diagram_section() -> None: js = _source(OUTPUT_PANEL_JS) - assert 'from "../mermaid_render.js?v=arch-diagram-v5"' in js - assert 'from "../architecture_diagram.js?v=eraser-browser-v14"' in js + assert 'from "../mermaid_render.js?v=cost-period-v6"' in js + assert 'from "../architecture_diagram.js?v=eraser-browser-v15"' in js assert "renderDiagramRow" in js assert '"Architecture diagram"' in js # 计数把 diagrams 计入(驱动徽标/自动显隐/自动弹出) @@ -12498,8 +12498,8 @@ def test_standalone_diagram_preview_supports_template_library_picker_drop_and_sh assert 'data-diagram-preview="corpus-select"' in html assert 'data-diagram-preview="random"' in html assert "/static/diagram-preview.css?v=diagram-preview-v2" in html - assert "/static/js/diagram_preview.js?v=diagram-preview-v7" in html - assert 'from "./architecture_diagram.js?v=eraser-browser-v14"' in source + assert "/static/js/diagram_preview.js?v=diagram-preview-v8" in html + assert 'from "./architecture_diagram.js?v=eraser-browser-v15"' in source assert 'from "./diagram_preview_library.js?v=template-library-v1"' in source assert '"/static/diagram-preview-corpus.json?v=visual-corpus-v1"' in source assert 'tokenFetch("/api/diagram-preview"' in source @@ -12557,8 +12557,8 @@ def test_diagram_preview_library_indexes_repository_and_randomizes_only_other_te def test_pipeline_candidate_inline_diagram_uses_web_diagrams() -> None: js = _source(PIPELINE_JS) - assert 'from "../mermaid_render.js?v=arch-diagram-v5"' in js - assert 'from "../architecture_diagram.js?v=eraser-browser-v14"' in js + assert 'from "../mermaid_render.js?v=cost-period-v6"' in js + assert 'from "../architecture_diagram.js?v=eraser-browser-v15"' in js assert "webDiagrams" in js # combinedDiagrams 合并 state.webDiagrams assert "pipeline-candidate-diagram" in js # 每卡可折叠架构图 assert "renderArchitectureDiagram" in js @@ -12571,7 +12571,7 @@ def test_pipeline_js_import_is_versioned() -> None: # pipeline.js 之前是 app.js 里唯一无版本位的 import;内容改动(含本轮 index 优先 # 匹配修复)在回访浏览器的 warm cache 下不会重新拉取。加版本位以确保修复落地。 app_source = _source(APP_JS) - assert "./components/pipeline.js?v=pipeline-solution-confirm-v16" in app_source + assert "./components/pipeline.js?v=pipeline-solution-confirm-v17" in app_source def test_solution_first_deployment_confirmation_uses_compact_actual_options() -> None: @@ -13158,7 +13158,7 @@ def test_app_regroups_pipeline_messages_before_render() -> None: def test_app_output_panel_import_bumped_for_desktop_external_links() -> None: js = _source(APP_JS) - assert "output-panel-v38" in js + assert "output-panel-v39" in js assert "output-panel-v24" not in js @@ -13325,11 +13325,12 @@ def _run_mermaid_render_script(tmp_path, source): return out.stdout -def test_render_diagram_price_with_cost(tmp_path): +@pytest.mark.parametrize("estimate", ["¥120/月", "¥3~¥5/小时(8小时共¥24~¥40)", "¥1200/年"]) +def test_render_diagram_price_with_cost(tmp_path, estimate): out = _run_mermaid_render_script( tmp_path, "const { renderDiagramPrice } = await import('__MERMAID_MODULE__');\n" - "const el = renderDiagramPrice({ totalMonthlyCost: '¥120/月'," + f"const el = renderDiagramPrice({{ totalMonthlyCost: {json.dumps(estimate)}," " costItems: [{ name: 'ECS', spec: '2c4g', monthly_cost: '¥100' }," " { name: 'RDS', monthly_cost: '¥20' }] });\n" "function findClass(n, cls){ if((n.className||'')===cls) return n;" @@ -13344,7 +13345,9 @@ def test_render_diagram_price_with_cost(tmp_path): ) data = json.loads(out.strip().splitlines()[-1]) assert data["hasTotal"] is True - assert "120" in data["totalText"] + assert estimate in data["totalText"] + assert "Estimated cost" in data["totalText"] + assert "Estimated monthly cost" not in data["totalText"] assert data["liCount"] == 2 assert data["empty"] is False