@delta-infra/cli 1.0.95 → 1.0.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +471 -467
  2. package/checksums.txt +5 -5
  3. package/package.json +1 -1
  4. package/scripts/install-wizard.js +701 -701
  5. package/scripts/install.js +442 -442
  6. package/scripts/run.js +65 -65
  7. package/skills/delta-sandbox/SKILL.md +356 -345
  8. package/skills/delta-sandbox/agents/openai.yaml +4 -4
  9. package/skills/delta-sandbox/references/commands.md +56 -58
  10. package/skills/delta-sandbox/references/lifecycle.md +92 -98
  11. package/skills/delta-sandbox/references/recipes.md +133 -138
  12. package/skills/delta-science/AGENTS.md +49 -49
  13. package/skills/delta-science/SKILL.md +141 -129
  14. package/skills/delta-science/agents/openai.yaml +4 -4
  15. package/skills/delta-science/references/antbo.md +100 -100
  16. package/skills/delta-science/references/cli-contract.md +53 -53
  17. package/skills/delta-science/references/delta-bo.md +89 -89
  18. package/skills/delta-science/references/generated-catalog.md +48 -0
  19. package/skills/delta-science/references/gsasii.md +116 -116
  20. package/skills/delta-science/references/lammps.md +66 -66
  21. package/skills/delta-science/references/ldm-bo.md +129 -129
  22. package/skills/delta-science/references/ldm.md +73 -73
  23. package/skills/delta-science/references/materials-design.md +109 -0
  24. package/skills/delta-science/references/molecule-enrich.md +59 -0
  25. package/skills/delta-science/references/operation-contracts.md +137 -0
  26. package/skills/delta-science/references/pubchem.md +126 -126
  27. package/skills/delta-science/references/pymatgen.md +68 -68
  28. package/skills/delta-science/references/qe-dft.md +123 -0
  29. package/skills/delta-science/references/rdkit.md +138 -138
  30. package/skills/delta-science/references/routing-index.md +35 -26
  31. package/skills/delta-science/references/services.md +112 -0
  32. package/skills/delta-science/references/strbo.md +87 -87
  33. package/skills/delta-science/references/synbo.md +198 -198
  34. package/skills/delta-science/references/workflows.md +86 -49
  35. package/skills/delta-science/scripts/catalog.py +92 -0
  36. package/skills/delta-science/scripts/generated_catalog.py +69 -0
  37. package/skills/delta-science/scripts/invoke.py +346 -0
  38. package/skills/delta-shared/SKILL.md +95 -97
  39. package/skills/delta-shared/agents/openai.yaml +4 -4
  40. package/skills/manifest.json +19 -19
@@ -1,61 +1,98 @@
1
- # 跨服务工作流
2
-
3
- 整个流程由单一 `delta-science` Skill 编排。线上调用最终执行
4
- `delta-cli science invoke`,禁止直接 HTTP。可使用 subagent 协助整理输入、核对 reference
5
- 或展示结果,但主流程负责工具选择和所有线上调用。CLI 成功后每一步只消费对应 reference
6
- 定义的 `data` 响应字段。已知 reference 中没有目标 operation 时,不猜测相近 endpoint;reference
7
- 缺失、过期或与 CLI 返回不一致时,按 `SKILL.md` 的只读实时 catalog 流程确认。
8
-
9
- ## 单个已命名分子
10
-
11
- 1. 用 PubChem `compound-summary` 解析名称并取得身份字段。
12
- 2. 按文档规定的回退顺序提取 SMILES。
13
- 3. 只有用户要求 RDKit 描述符或明确的结构校验时,才调用 RDKit `batch-parse-describe`。
1
+ # 跨服务工作流
2
+
3
+ 整个流程由单一 `delta-science` Skill 编排。线上调用最终执行
4
+ `delta-cli science invoke`,禁止直接 HTTP。可使用 subagent 协助整理输入、核对 reference
5
+ 或展示结果,但主流程负责工具选择和所有线上调用。CLI 成功后每一步只消费对应 reference
6
+ 定义的 `data` 响应字段。已知 reference 中没有目标 operation 时,不猜测相近 endpoint;reference
7
+ 缺失、过期或与 CLI 返回不一致时,按 `SKILL.md` 的只读实时 catalog 流程确认。
8
+
9
+ ## 单个已命名分子
10
+
11
+ 1. 用 PubChem `compound-summary` 解析名称并取得身份字段。
12
+ 2. 按文档规定的回退顺序提取 SMILES。
13
+ 3. 只有用户要求 RDKit 描述符或明确的结构校验时,才调用 RDKit `batch-parse-describe`。
14
14
  4. 只有用户明确要求图片时,才调用 RDKit `render`,并解码、验证图片字节。
15
15
  5. 比较分子式、分子量、InChIKey 和供体/受体数量时,要明确区分 LogP、TPSA 等依赖模型的数值。
16
16
 
17
- ## 多分子比较
18
-
19
- 1. 用 PubChem 批量解析全部名称,请求体只包含 `identifiers` 和 `namespace:"name"`。省略 `properties`。
20
- 2. 从业务响应的 `results[*]` 读取记录,并从每条记录的嵌套 `properties` 读取字段。
21
- 3. 未解析成功的记录要排除,并明确说明原因。
22
- 4. 使用 RDKit `similarity-matrix` 校验和排序两两相似度。它的 `sanitize` 已经校验输入,不要额外调用 parse。
23
- 5. 用 PubChem 记录的原始 `input` 作为 molecule id,并按文档顺序从嵌套属性中提取 SMILES。
24
- 6. 保持输入顺序,报告每个分数时明确对应的分子对。
25
- 7. 除非用户明确要求,不要渲染、保存用户文件、检查健康状态或探测额外描述符。
26
- 8. `ranked_pairs` 使用命名字段 `a`、`b`、`similarity`。找指定目标的最近邻时,
27
- 在包含该目标的记录中取最大 `similarity`,另一侧字段就是最近邻;不要使用
28
- `i`、`j`、`score` 或猜测矩阵下标。
29
-
30
- ## 材料工作流
31
-
32
- 1. 使用 pymatgen 校验化学式/结构并转换 CIF;无机式量只能取当前业务响应 `weight`。
33
- 2. GSAS-II 只能接收真实 CIF 文本后再做衍射计算。
17
+ 如果用户明确要求一次返回 PubChem 记录以及 RDKit 规范化/描述符/指纹,使用
18
+ `chemistry/molecule.enrich`;只要 PubChem 身份信息或只要 RDKit 计算时,仍使用对应的
19
+ 单服务 operation。
20
+
21
+ ## 多分子比较
22
+
23
+ 1. 用 PubChem 批量解析全部名称,请求体只包含 `identifiers` 和 `namespace:"name"`。省略 `properties`。
24
+ 2. 从业务响应的 `results[*]` 读取记录,并从每条记录的嵌套 `properties` 读取字段。
25
+ 3. 未解析成功的记录要排除,并明确说明原因。
26
+ 4. 使用 RDKit `similarity-matrix` 校验和排序两两相似度。它的 `sanitize` 已经校验输入,不要额外调用 parse。
27
+ 5. 用 PubChem 记录的原始 `input` 作为 molecule id,并按文档顺序从嵌套属性中提取 SMILES。
28
+ 6. 保持输入顺序,报告每个分数时明确对应的分子对。
29
+ 7. 除非用户明确要求,不要渲染、保存用户文件、检查健康状态或探测额外描述符。
30
+ 8. `ranked_pairs` 使用命名字段 `a`、`b`、`similarity`。找指定目标的最近邻时,
31
+ 在包含该目标的记录中取最大 `similarity`,另一侧字段就是最近邻;不要使用
32
+ `i`、`j`、`score` 或猜测矩阵下标。
33
+
34
+ ## 材料工作流
35
+
36
+ 1. 使用 pymatgen 校验化学式/结构并转换 CIF;无机式量只能取当前业务响应 `weight`。
37
+ 2. GSAS-II 只能接收真实 CIF 文本后再做衍射计算。
34
38
  3. LAMMPS 只能在已有明确的力场、data 和脚本设置后运行;部署检查可以使用内置有界示例。
35
39
  4. 从 LAMMPS 返回 pymatgen 或 GSAS-II 前,需要从弛豫结果重新构造 CIF/POSCAR。不要直接传递 LAMMPS data/dump 文件。
36
40
 
37
- ## 优化工作流
41
+ ## Materials Design 工作流
38
42
 
39
- - 通用数值或分类实验变量:Delta-BO
40
- - 固定 SMILES 候选池或 PDF2Dock/Vina+NN 轨迹:LDM-BO
41
- - 固定 KRAS G12D 的生成式小分子闭环:STRBO
42
- - 指定抗原的 11-aa CDRH3 闭环:AntBO
43
- - 偶联反应、反应条件和带真实历史得率的下一批实验推荐:SynBO
43
+ 1. 先明确 identifier、可控设计变量、目标列及方向、泄漏/元数据列、分组或时间切分和
44
+ 可行候选空间;不能优化 identifier、测量后变量或泄漏字段。
45
+ 2. 优先使用用户提供的候选表;只有服务契约允许且假设已标明时,才从可控变量的观测域
46
+ 生成离散候选。组成闭合、硬约束、步长和是否允许外推必须显式记录。
47
+ 3. 读取作业的 `status` 后再读取 `result_status`;`completed` 不等于推荐可靠。
48
+ `complete`、`exploratory`、`readiness_only` 和 `failed` 必须分别报告,并保留 warnings。
49
+ 4. 只有用户要求或工作流需要时才下载 report/CSV/JSON artifact;依据服务返回的 size 和
50
+ SHA-256 校验,不能声称结果已经写入本地项目目录。
44
51
 
45
- 用户以 “LDM/大发现模型” 描述任务时,必须先按 `SKILL.md` 读取 `ldm.md`,再读取唯一
46
- 选定后端的 reference。LDM 是工作流意图,不是 tool;禁止旧
47
- `large-discovery-model`、`run_ldm_loop.py`、gateway backend 和直接 HTTP。
52
+ Materials Design 的 `jobs` 上传是 multipart。当前 `delta-cli science invoke` 只能传 JSON
53
+ body/query;除非实时目录提供明确的文件上传 adapter,否则不猜测 endpoint、不要把文件
54
+ 路径或 base64 塞进 `--data`,也不要改走直连 Gateway。
48
55
 
49
- 禁止为了让优化器运行而编造观测数据。用户没有提供测量结果时,只能使用初始化或随机建议模式,并明确说明它是待实验验证的建议,不是已经验证的最优条件。
50
- SynBO/Delta-BO 没有返回 recommendations 时,不得用本地回归、GP、UCB 或人工排序替代。
56
+ ## QE DFT 工作流
51
57
 
52
- SynBO 只接受离散条件列表。用户给连续范围时,在一次调用前完成以下处理:
58
+ 1. 读取 health 和实时 workflow catalog,选择当前为 `supported` 的 workflow;收集完整
59
+ campaign element union,并按服务返回值解析/锁定 PP。
60
+ 2. 结构必须先成为服务返回的 checksum-addressed artifact;job 只引用 artifact ID、已批准
61
+ parameter set、PP lock、k-points、workflow_inputs 和资源请求。
62
+ 3. 提交必须使用稳定幂等语义;HTTP 202/accepted 只表示接收,不表示 QE 已收敛。轮询时
63
+ 独立读取 `execution`、`solver`、`scientific` 三个状态,并按原始 stable error code 分类。
64
+ 4. CPU 是默认资源策略。只有用户明确要求或项目已有 GPU 授权且 live catalog 标记为
65
+ `gpu_support_status: verified` 时才请求 GPU;`gpu` 不得静默回退到 CPU,`auto` 也不等于
66
+ 已实际使用 GPU。
67
+ 5. 日志和结果 artifact 下载后按服务返回的 SHA-256 校验;不要把 scheduler 完成、solver
68
+ 收敛或可解析输出直接写成 scientific acceptance。
53
69
 
54
- 1. 条件网格包含用户上下界、全部历史观测值和少量合理中间点。
55
- 2. 同一条件列在 `condition_dict` 与 `previous_results` 中统一为字符串。
56
- 3. 得率等 objective metric 保持数值。
57
- 4. 小数据 CPU 默认 `tiny + RF + UCB`,并在最终答案中报告实际离散网格。
58
- 5. CLI 返回失败或业务响应为空 recommendations 时结束;不得连续尝试多种 payload 或模型。
70
+ QE 原始 artifact 上传需要二进制 body,job 提交还需要自定义 `Idempotency-Key`。当前 CLI
71
+ 没有这两类参数;只能调用 Science Server 已明确暴露的等价 adapter,否则报告“当前 CLI
72
+ 未暴露该 operation;未发送远端请求”。
59
73
 
60
- 用户未要求文件时只返回 recommendations 文本,不创建输入 CSV。必须交接时,仅把本次
61
- 成功业务响应写入当前 `WORKSPACE_ROOT/synbo_result.json`。
74
+ ## 优化工作流
75
+
76
+ - 通用数值或分类实验变量:Delta-BO
77
+ - 固定 SMILES 候选池或 PDF2Dock/Vina+NN 轨迹:LDM-BO
78
+ - 固定 KRAS G12D 的生成式小分子闭环:STRBO
79
+ - 指定抗原的 11-aa CDRH3 闭环:AntBO
80
+ - 偶联反应、反应条件和带真实历史得率的下一批实验推荐:SynBO
81
+
82
+ 用户以 “LDM/大发现模型” 描述任务时,必须先按 `SKILL.md` 读取 `ldm.md`,再读取唯一
83
+ 选定后端的 reference。LDM 是工作流意图,不是 tool;禁止旧
84
+ `large-discovery-model`、`run_ldm_loop.py`、gateway backend 和直接 HTTP。
85
+
86
+ 禁止为了让优化器运行而编造观测数据。用户没有提供测量结果时,只能使用初始化或随机建议模式,并明确说明它是待实验验证的建议,不是已经验证的最优条件。
87
+ SynBO/Delta-BO 没有返回 recommendations 时,不得用本地回归、GP、UCB 或人工排序替代。
88
+
89
+ SynBO 只接受离散条件列表。用户给连续范围时,在一次调用前完成以下处理:
90
+
91
+ 1. 条件网格包含用户上下界、全部历史观测值和少量合理中间点。
92
+ 2. 同一条件列在 `condition_dict` 与 `previous_results` 中统一为字符串。
93
+ 3. 得率等 objective metric 保持数值。
94
+ 4. 小数据 CPU 默认 `tiny + RF + UCB`,并在最终答案中报告实际离散网格。
95
+ 5. CLI 返回失败或业务响应为空 recommendations 时结束;不得连续尝试多种 payload 或模型。
96
+
97
+ 用户未要求文件时只返回 recommendations 文本,不创建输入 CSV。必须交接时,仅把本次
98
+ 成功业务响应写入当前 `WORKSPACE_ROOT/synbo_result.json`。
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python3
2
+ """Read the authoritative Delta Science catalog through delta-cli."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import time
12
+
13
+ from invoke import _parse_cli_error, _resolve_cli, _runtime_config
14
+
15
+
16
+ def _parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(description=__doc__)
18
+ action = parser.add_mutually_exclusive_group(required=True)
19
+ action.add_argument("--tools", action="store_true")
20
+ action.add_argument("--endpoints", metavar="TOOL")
21
+ parser.add_argument("--cli")
22
+ parser.add_argument("--science-base-url")
23
+ parser.add_argument("--timeout", type=float, default=30.0)
24
+ return parser
25
+
26
+
27
+ def main() -> int:
28
+ args = _parser().parse_args()
29
+ started = time.perf_counter()
30
+ kind = "tools" if args.tools else "endpoints"
31
+ try:
32
+ runtime = _runtime_config()
33
+ cli = _resolve_cli(args.cli, runtime)
34
+ env = os.environ.copy()
35
+ base_url = args.science_base_url or runtime.get("science_base_url")
36
+ if base_url:
37
+ env["DELTA_INFRA_SCIENCE_BASE_URL"] = str(base_url)
38
+
39
+ command = [cli, "science", "list"]
40
+ if args.endpoints:
41
+ command = [cli, "science", "endpoints", "list", args.endpoints]
42
+ completed = subprocess.run(
43
+ command,
44
+ capture_output=True,
45
+ text=True,
46
+ encoding="utf-8",
47
+ errors="replace",
48
+ env=env,
49
+ timeout=args.timeout,
50
+ check=False,
51
+ )
52
+ if completed.returncode != 0:
53
+ error_type, detail = _parse_cli_error(completed)
54
+ raise RuntimeError(f"{error_type}: {detail}")
55
+ root = json.loads(completed.stdout)
56
+ if not isinstance(root, dict) or root.get("ok") is not True:
57
+ raise RuntimeError(json.dumps(root, ensure_ascii=False))
58
+ if "data" not in root:
59
+ raise RuntimeError("CLI success envelope is missing data")
60
+ print(json.dumps({
61
+ "ok": True,
62
+ "transport": "delta-cli",
63
+ "catalog": kind,
64
+ "tool": args.endpoints,
65
+ "elapsed_seconds": round(time.perf_counter() - started, 3),
66
+ "native": root["data"],
67
+ }, ensure_ascii=False))
68
+ return 0
69
+ except subprocess.TimeoutExpired:
70
+ error_type = "timeout"
71
+ error = f"timeout after {args.timeout}s"
72
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
73
+ error_type = "catalog"
74
+ error = str(exc)
75
+ except RuntimeError as exc:
76
+ error_type = "cli"
77
+ error = str(exc)
78
+
79
+ print(json.dumps({
80
+ "ok": False,
81
+ "transport": "delta-cli",
82
+ "catalog": kind,
83
+ "tool": args.endpoints,
84
+ "error_type": error_type,
85
+ "elapsed_seconds": round(time.perf_counter() - started, 3),
86
+ "error": error[:2000],
87
+ }, ensure_ascii=False))
88
+ return 1
89
+
90
+
91
+ if __name__ == "__main__":
92
+ sys.exit(main())
@@ -0,0 +1,69 @@
1
+ """Generated Science catalog aliases. Do not edit by hand."""
2
+
3
+ LEGACY_TOOL_ALIASES = {
4
+ "synbo": "synbo-service",
5
+ "antbo": "antbo-service",
6
+ }
7
+
8
+ LEGACY_ENDPOINTS = {
9
+ "pubchem": {
10
+ "health": "chem_pubchem_health",
11
+ "compound-resolve-cids": "chem_pubchem_compound_resolve-cids",
12
+ "compound-properties": "chem_pubchem_compound_properties",
13
+ "compound-synonyms": "chem_pubchem_compound_synonyms",
14
+ "compound-summary": "chem_pubchem_compound_summary",
15
+ "compound-batch-summary": "chem_pubchem_compound_batch-summary",
16
+ },
17
+ "rdkit": {
18
+ "health": "chem_rdkit_health",
19
+ "parse": "chem_rdkit_parse",
20
+ "descriptors": "chem_rdkit_descriptors",
21
+ "batch-descriptors": "chem_rdkit_batch-descriptors",
22
+ "batch-parse-describe": "chem_rdkit_batch-parse-describe",
23
+ "render": "chem_rdkit_render",
24
+ "fingerprint": "chem_rdkit_fingerprint",
25
+ "similarity": "chem_rdkit_similarity",
26
+ "similarity-matrix": "chem_rdkit_similarity-matrix",
27
+ "substructure": "chem_rdkit_substructure",
28
+ },
29
+ "pymatgen": {
30
+ "health": "chem_pymatgen_health",
31
+ "composition-parse": "chem_pymatgen_composition_parse",
32
+ "structure-parse": "chem_pymatgen_structure_parse",
33
+ "structure-summary": "chem_pymatgen_structure_summary",
34
+ "structure-convert": "chem_pymatgen_structure_convert",
35
+ "structure-symmetry": "chem_pymatgen_structure_symmetry",
36
+ },
37
+ "gsasii": {
38
+ "health": "chem_gsasii_health",
39
+ "powder-simulate": "chem_gsasii_powder_simulate",
40
+ "powder-refine": "chem_gsasii_powder_refine",
41
+ },
42
+ "lammps": {
43
+ "health": "chem_lammps_health",
44
+ "lj-melt-example": "chem_lammps_examples_lj-melt",
45
+ "run": "chem_lammps_run",
46
+ },
47
+ "delta-bo": {
48
+ "commands": "chem_delta-bo_commands",
49
+ "generate": "chem_delta-bo_generate",
50
+ },
51
+ "ldm-bo": {
52
+ "health": "chem_ldm-bo_health",
53
+ "recommend": "chem_ldm-bo_recommend",
54
+ "trajectory": "chem_ldm-bo_trajectory",
55
+ },
56
+ "synbo": {
57
+ "health": "chem_synbo_health",
58
+ "initialize": "chem_synbo_initialize",
59
+ "optimize": "chem_synbo_optimize",
60
+ },
61
+ "antbo": {
62
+ "health": "biology_antbo_health",
63
+ "run-default-job": "biology_antbo_run_default_job",
64
+ "run": "biology_antbo_run",
65
+ "log": "biology_antbo_log",
66
+ "jobs": "biology_antbo_jobs",
67
+ "stop": "biology_antbo_stop",
68
+ },
69
+ }
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env python3
2
+ """Invoke one Delta Science operation strictly through delta-cli."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from typing import Any
15
+
16
+ SKILL_DIR = Path(__file__).resolve().parents[1]
17
+ _CLI_ERROR_TYPES = {
18
+ 2: "validation",
19
+ 3: "auth",
20
+ 4: "permission",
21
+ 5: "not_found",
22
+ 6: "network",
23
+ 7: "api",
24
+ 10: "internal",
25
+ }
26
+
27
+
28
+ def _json_object(raw: str | None, flag: str) -> dict[str, Any] | None:
29
+ if raw is None:
30
+ return None
31
+ try:
32
+ value = json.loads(raw)
33
+ except json.JSONDecodeError as exc:
34
+ raise ValueError(f"{flag} is not valid JSON: {exc}") from exc
35
+ if not isinstance(value, dict):
36
+ raise ValueError(f"{flag} must decode to a JSON object")
37
+ return value
38
+
39
+
40
+ def _normalize_synbo_payload(
41
+ tool: str,
42
+ endpoint: str,
43
+ data: dict[str, Any] | None,
44
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
45
+ """Normalize SynBO's discrete condition contract before any remote request."""
46
+ canonical_tool = tool.removesuffix("-service")
47
+ if canonical_tool != "synbo" or endpoint not in {"initialize", "optimize"}:
48
+ return data, None
49
+ if data is None:
50
+ raise ValueError(f"synbo/{endpoint} requires --data-json")
51
+
52
+ condition_dict = data.get("condition_dict")
53
+ if not isinstance(condition_dict, dict) or not condition_dict:
54
+ raise ValueError("SynBO condition_dict must be a non-empty object")
55
+
56
+ normalized_conditions: dict[str, list[str]] = {}
57
+ for name, raw_values in condition_dict.items():
58
+ if not isinstance(raw_values, list) or not raw_values:
59
+ raise ValueError(
60
+ f"SynBO condition_dict.{name} must be a non-empty explicit list; "
61
+ "range objects are not supported"
62
+ )
63
+ values = [str(value) for value in raw_values]
64
+ if any(value == "" for value in values):
65
+ raise ValueError(f"SynBO condition_dict.{name} contains an empty value")
66
+ if len(set(values)) != len(values):
67
+ raise ValueError(
68
+ f"SynBO condition_dict.{name} contains duplicate values after "
69
+ "string normalization"
70
+ )
71
+ normalized_conditions[str(name)] = values
72
+
73
+ normalized = dict(data)
74
+ normalized["condition_dict"] = normalized_conditions
75
+ normalization = {
76
+ "condition_values": "strings",
77
+ "condition_space_size": _condition_space_size(normalized_conditions),
78
+ }
79
+
80
+ if endpoint == "initialize":
81
+ return normalized, normalization
82
+
83
+ metrics = normalized.get("opt_metrics")
84
+ if not isinstance(metrics, list) or not metrics:
85
+ raise ValueError("SynBO optimize requires a non-empty opt_metrics list")
86
+ metric_names = [str(metric) for metric in metrics]
87
+ normalized["opt_metrics"] = metric_names
88
+
89
+ previous_results = normalized.get("previous_results")
90
+ if not isinstance(previous_results, list) or not previous_results:
91
+ raise ValueError("SynBO optimize requires non-empty previous_results")
92
+
93
+ normalized_rows: list[dict[str, Any]] = []
94
+ for index, raw_row in enumerate(previous_results):
95
+ if not isinstance(raw_row, dict):
96
+ raise ValueError(f"SynBO previous_results[{index}] must be an object")
97
+ row = dict(raw_row)
98
+ for condition_name, allowed_values in normalized_conditions.items():
99
+ if condition_name not in row:
100
+ raise ValueError(
101
+ f"SynBO previous_results[{index}] is missing condition "
102
+ f"{condition_name!r}"
103
+ )
104
+ value = str(row[condition_name])
105
+ if value not in allowed_values:
106
+ raise ValueError(
107
+ f"SynBO previous_results[{index}].{condition_name}={value!r} "
108
+ "is not present in condition_dict"
109
+ )
110
+ row[condition_name] = value
111
+ for metric_name in metric_names:
112
+ metric_value = row.get(metric_name)
113
+ if (
114
+ metric_name not in row
115
+ or isinstance(metric_value, bool)
116
+ or not isinstance(metric_value, (int, float))
117
+ ):
118
+ raise ValueError(
119
+ f"SynBO previous_results[{index}].{metric_name} must be numeric"
120
+ )
121
+ normalized_rows.append(row)
122
+
123
+ normalized["previous_results"] = normalized_rows
124
+ normalized.setdefault("accuracy", "tiny")
125
+ normalized.setdefault("device", "cpu")
126
+ normalized.setdefault("surrogate_model", "RF")
127
+ normalized.setdefault("acq_func", "UCB")
128
+ normalization["defaults"] = {
129
+ key: normalized[key]
130
+ for key in ("accuracy", "device", "surrogate_model", "acq_func")
131
+ }
132
+ return normalized, normalization
133
+
134
+
135
+ def _condition_space_size(condition_dict: dict[str, list[str]]) -> int:
136
+ size = 1
137
+ for values in condition_dict.values():
138
+ size *= len(values)
139
+ return size
140
+
141
+
142
+ def _runtime_config() -> dict[str, Any]:
143
+ path = SKILL_DIR / "runtime.local.json"
144
+ if not path.is_file():
145
+ return {}
146
+ try:
147
+ value = json.loads(path.read_text(encoding="utf-8"))
148
+ except (OSError, json.JSONDecodeError):
149
+ return {}
150
+ return value if isinstance(value, dict) else {}
151
+
152
+
153
+ def _resolve_cli(explicit: str | None, runtime: dict[str, Any]) -> str:
154
+ candidates: list[str | None] = [
155
+ explicit,
156
+ os.environ.get("DELTA_CLI_PATH"),
157
+ runtime.get("cli_path"),
158
+ shutil.which("delta-cli"),
159
+ shutil.which("delta-cli.exe"),
160
+ ]
161
+ package_root = SKILL_DIR.parents[1]
162
+ exe_name = "delta-cli.exe" if os.name == "nt" else "delta-cli"
163
+ candidates.append(str(package_root / "bin" / exe_name))
164
+ for candidate in candidates:
165
+ if candidate and Path(candidate).is_file():
166
+ return str(Path(candidate).resolve())
167
+ raise FileNotFoundError(
168
+ "delta-cli not found; install @delta-infra/cli, set DELTA_CLI_PATH, "
169
+ "or add cli_path to delta-science/runtime.local.json"
170
+ )
171
+
172
+
173
+ def _unwrap(stdout: str) -> tuple[Any, int]:
174
+ try:
175
+ root = json.loads(stdout)
176
+ except json.JSONDecodeError as exc:
177
+ raise RuntimeError(f"CLI stdout is not JSON: {exc}") from exc
178
+ if not isinstance(root, dict):
179
+ raise RuntimeError("CLI stdout root is not an object")
180
+ if root.get("ok") is not True:
181
+ raise RuntimeError(json.dumps(root.get("error", root), ensure_ascii=False))
182
+ if "data" not in root:
183
+ raise RuntimeError("CLI success envelope is missing data")
184
+
185
+ value: Any = root["data"]
186
+ depth = 0
187
+ while isinstance(value, dict):
188
+ if "status_code" in value and "data" in value:
189
+ status_code = value.get("status_code")
190
+ if not isinstance(status_code, int) or not 200 <= status_code < 300:
191
+ raise RuntimeError(json.dumps(value, ensure_ascii=False))
192
+ value = value["data"]
193
+ elif (
194
+ "code" in value
195
+ and "data" in value
196
+ and set(value).intersection({"message", "msg"})
197
+ ):
198
+ code = value.get("code")
199
+ if code not in (0, "0", None):
200
+ raise RuntimeError(json.dumps(value, ensure_ascii=False))
201
+ value = value["data"]
202
+ else:
203
+ break
204
+ depth += 1
205
+ if depth > 4:
206
+ raise RuntimeError("too many nested service envelopes")
207
+ if isinstance(value, dict) and value.get("valid") is False:
208
+ raise RuntimeError(json.dumps({
209
+ "valid": False,
210
+ "error": value.get("error"),
211
+ "warnings": value.get("warnings"),
212
+ }, ensure_ascii=False))
213
+ return value, depth
214
+
215
+
216
+ def _validate_native(tool: str, endpoint: str, native: Any) -> None:
217
+ canonical_tool = tool.removesuffix("-service")
218
+ if canonical_tool == "synbo" and endpoint == "optimize":
219
+ if not isinstance(native, dict):
220
+ raise RuntimeError("SynBO optimize returned a non-object result")
221
+ recommendations = native.get("recommendations")
222
+ if not isinstance(recommendations, list) or not recommendations:
223
+ raise RuntimeError("SynBO optimize returned no recommendations")
224
+
225
+
226
+ def _parse_cli_error(completed: subprocess.CompletedProcess[str]) -> tuple[str, str]:
227
+ detail = completed.stderr.strip() or completed.stdout.strip()
228
+ error_type = _CLI_ERROR_TYPES.get(completed.returncode, "cli")
229
+ try:
230
+ envelope = json.loads(detail)
231
+ error = envelope.get("error", {}) if isinstance(envelope, dict) else {}
232
+ if isinstance(error, dict):
233
+ error_type = str(error.get("type") or error_type)
234
+ message = error.get("message")
235
+ if message:
236
+ detail = str(message)
237
+ except (AttributeError, json.JSONDecodeError):
238
+ pass
239
+ return error_type, detail
240
+
241
+
242
+ def _parser() -> argparse.ArgumentParser:
243
+ parser = argparse.ArgumentParser(description=__doc__)
244
+ parser.add_argument("--tool", required=True)
245
+ parser.add_argument("--endpoint", required=True)
246
+ body = parser.add_mutually_exclusive_group()
247
+ body.add_argument("--data-json")
248
+ body.add_argument("--params-json")
249
+ parser.add_argument("--cli")
250
+ parser.add_argument("--science-base-url")
251
+ parser.add_argument("--timeout", type=float, default=120.0)
252
+ return parser
253
+
254
+
255
+ def main() -> int:
256
+ args = _parser().parse_args()
257
+ started = time.perf_counter()
258
+ stage = "arguments"
259
+ cli_exit_code: int | None = None
260
+ error_type = "unknown"
261
+ normalization: dict[str, Any] | None = None
262
+ try:
263
+ data = _json_object(args.data_json, "--data-json")
264
+ params = _json_object(args.params_json, "--params-json")
265
+ data, normalization = _normalize_synbo_payload(
266
+ args.tool, args.endpoint, data
267
+ )
268
+ runtime = _runtime_config()
269
+ stage = "cli-resolution"
270
+ cli = _resolve_cli(args.cli, runtime)
271
+
272
+ env = os.environ.copy()
273
+ base_url = args.science_base_url or runtime.get("science_base_url")
274
+ if base_url:
275
+ env["DELTA_INFRA_SCIENCE_BASE_URL"] = str(base_url)
276
+
277
+ command = [
278
+ cli, "science", "invoke",
279
+ "--tool", args.tool,
280
+ "--endpoint", args.endpoint,
281
+ ]
282
+ if data is not None:
283
+ command.extend(["--data", json.dumps(data, ensure_ascii=False, separators=(",", ":"))])
284
+ if params is not None:
285
+ command.extend(["--params", json.dumps(params, ensure_ascii=False, separators=(",", ":"))])
286
+
287
+ stage = "delta-cli"
288
+ completed = subprocess.run(
289
+ command,
290
+ capture_output=True,
291
+ text=True,
292
+ encoding="utf-8",
293
+ errors="replace",
294
+ env=env,
295
+ timeout=args.timeout,
296
+ check=False,
297
+ )
298
+ if completed.returncode != 0:
299
+ cli_exit_code = completed.returncode
300
+ error_type, detail = _parse_cli_error(completed)
301
+ raise RuntimeError(f"exit_code={completed.returncode}: {detail}")
302
+ stage = "response-validation"
303
+ native, depth = _unwrap(completed.stdout)
304
+ _validate_native(args.tool, args.endpoint, native)
305
+ result = {
306
+ "ok": True,
307
+ "transport": "delta-cli",
308
+ "tool": args.tool,
309
+ "endpoint": args.endpoint,
310
+ "elapsed_seconds": round(time.perf_counter() - started, 3),
311
+ "envelope_depth": depth,
312
+ "native": native,
313
+ }
314
+ if normalization is not None:
315
+ result["request_normalization"] = normalization
316
+ print(json.dumps(result, ensure_ascii=False))
317
+ return 0
318
+ except subprocess.TimeoutExpired:
319
+ error_type = "timeout"
320
+ error = f"timeout after {args.timeout}s"
321
+ except ValueError as exc:
322
+ error_type = "validation"
323
+ error = str(exc)
324
+ except OSError as exc:
325
+ error_type = "cli_resolution"
326
+ error = str(exc)
327
+ except RuntimeError as exc:
328
+ if error_type == "unknown":
329
+ error_type = "response_validation"
330
+ error = str(exc)
331
+ print(json.dumps({
332
+ "ok": False,
333
+ "transport": "delta-cli",
334
+ "tool": args.tool,
335
+ "endpoint": args.endpoint,
336
+ "stage": stage,
337
+ "error_type": error_type,
338
+ "cli_exit_code": cli_exit_code,
339
+ "elapsed_seconds": round(time.perf_counter() - started, 3),
340
+ "error": error[:2000],
341
+ }, ensure_ascii=False))
342
+ return 1
343
+
344
+
345
+ if __name__ == "__main__":
346
+ sys.exit(main())