run_procedure_dsl.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841
  1. #!/usr/bin/env python3
  2. """
  3. run_procedure_dsl.py — 拿 procedure-dsl/spec.md 当指令本, 让 Claude Agent SDK
  4. (走 ~/.claude OAuth Max 订阅额度) 对单个 post 跑完三阶段提取, 落 case-N.{md,html}.
  5. 设计:
  6. - 复用 Agent 仓 examples/process_pipeline/run_pipeline.py --use-claude-sdk 的 OAuth
  7. 思路: 子进程 env 把 ANTHROPIC_API_KEY / BASE_URL / AUTH_TOKEN 显式置空, 让
  8. claude CLI 回落到 OAuth 凭证.
  9. - 与 agent/llm/claude_code_oauth.py 的 one-shot wrapper 不同: 那个 max_turns=1
  10. + allowed_tools=[], 本脚本让 Agent 真正自主跑——开 Read/Write/Bash/Edit/Glob/Grep,
  11. multi-turn, 自己读 spec.md / source / 调 bin/taxonomy-lookup.py / 写产物.
  12. - 单 post 输入, 多终端 = 多并行 (用户自己开).
  13. 用法 (本脚本位于 procedure-dsl/, SDK cwd 默认设到 procedure-dsl/):
  14. # source 是 input/case-N-raw.json, 脚本自动从 image_url_list 拉图作多模态.
  15. # --out-dir 必填: 是 outputs/ 下的目录名, 产物全落这里; case_id 自动从它的 basename 推.
  16. python run_procedure_dsl.py input/case-2-raw.json --out-dir case-2
  17. python run_procedure_dsl.py input/case-2-raw.json --out-dir case-2 \\
  18. --extra-image /path/to/local-ref.png --model claude-sonnet-4-6
  19. # 跑实验版 spec-test/ 对比 (产物落 outputs/case-2_test/):
  20. python run_procedure_dsl.py input/case-2-raw.json --out-dir case-2_test --version test
  21. # 中断后恢复 (产物保留, agent 重读磁盘接着跑):
  22. python run_procedure_dsl.py input/case-2-raw.json --out-dir case-2 --resume
  23. source 文件 schema (procedure-dsl/input/*.json 约定):
  24. {
  25. "title": str, "link": str, "body_text": str,
  26. "image_url_list": [{"image_type": int, "image_url": str}, ...],
  27. "publish_timestamp": ..., "channel_account_name": str,
  28. }
  29. """
  30. import argparse
  31. import asyncio
  32. import base64
  33. import json
  34. import logging
  35. import sys
  36. import time
  37. from datetime import datetime
  38. from pathlib import Path
  39. from typing import Any, Dict, List
  40. # run_procedure_dsl.py → procedure-dsl/
  41. DSL_ROOT = Path(__file__).resolve().parent
  42. def _derive_case_id(out_dir: str) -> str:
  43. """从 --out-dir 自动派生 case_id (用于 prompt 文件名 + case_data.case_id + suggestions record).
  44. 规则: Path(out_dir).name 去掉 "case-" 前缀 (--out-dir 是 outputs/ 下的相对名).
  45. --out-dir case-5 → "5"
  46. --out-dir case-5-newflow → "5-newflow"
  47. --out-dir photo-album → "photo-album" (无 case- 前缀也行)
  48. --out-dir case-11 → "11"
  49. 这样用户传 --out-dir 控制目录名, case_id 自动跟随 — 不再撞历史 case 目录.
  50. """
  51. name = Path(out_dir).name
  52. if name.startswith("case-"):
  53. name = name[5:]
  54. return name or "?"
  55. def _resolve_out_dir(out_dir_arg: str, workdir: Path) -> Path:
  56. """把 --out-dir 解析成实际工作目录. --out-dir 是 `outputs/` 下的相对名:
  57. --out-dir newdir → <workdir>/outputs/newdir
  58. --out-dir case-5 → <workdir>/outputs/case-5
  59. --out-dir outputs/newdir → <workdir>/outputs/newdir (容忍已带 outputs/ 前缀, 不重复嵌套)
  60. 传绝对路径则原样用 (escape hatch).
  61. """
  62. p = Path(out_dir_arg)
  63. if p.is_absolute():
  64. return p.resolve()
  65. parts = p.parts
  66. if parts and parts[0] == "outputs": # 容忍历史习惯的 outputs/ 前缀
  67. parts = parts[1:]
  68. base = workdir / "outputs"
  69. return (base / Path(*parts)).resolve() if parts else base.resolve()
  70. # Sonnet 4.6 公开价目 (per 1M tokens, USD), 用于 estimated cost.
  71. # OAuth Max 模式 SDK 报的 total_cost_usd 通常是 None — 我们自己按 token 算个
  72. # "如果走 API 等价多少钱"作为 quota 消耗的直观代理.
  73. PRICE_SONNET_4_6 = {
  74. "input": 3.00,
  75. "output": 15.00,
  76. "cache_creation": 3.75,
  77. "cache_read": 0.30,
  78. }
  79. def _estimate_cost_usd(usage: Dict[str, Any]) -> float:
  80. if not usage:
  81. return 0.0
  82. return (
  83. usage.get("input_tokens", 0) / 1_000_000 * PRICE_SONNET_4_6["input"]
  84. + usage.get("output_tokens", 0) / 1_000_000 * PRICE_SONNET_4_6["output"]
  85. + usage.get("cache_creation_input_tokens", 0) / 1_000_000 * PRICE_SONNET_4_6["cache_creation"]
  86. + usage.get("cache_read_input_tokens", 0) / 1_000_000 * PRICE_SONNET_4_6["cache_read"]
  87. )
  88. # ──── 多模态: 图片 → Anthropic content block ──────────────────────────────────
  89. _MEDIA_TYPE = {
  90. ".png": "image/png",
  91. ".jpg": "image/jpeg",
  92. ".jpeg": "image/jpeg",
  93. ".gif": "image/gif",
  94. ".webp": "image/webp",
  95. }
  96. # client-side image cache: DSL_ROOT/.image_cache/<sha256>.<ext>
  97. # 同一 URL 多次跑 / resume 不重复下载. 不影响 SDK / OAuth, 纯本地 IO.
  98. _IMAGE_CACHE_DIR = DSL_ROOT / ".image_cache"
  99. # 浏览器 UA, 避免 mmbiz / 其他图床对裸 python-requests 默认 UA 直接 403.
  100. _DOWNLOAD_UA = (
  101. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
  102. "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
  103. )
  104. def _url_to_cached_path(url: str, timeout: float = 15.0) -> Path:
  105. """下载 url 到 _IMAGE_CACHE_DIR, 返回本地路径. 已缓存直接返回不重下.
  106. 用 URL 的 sha256 当文件名, 扩展名从 URL path 推断 (找不到默认 .png).
  107. 设计目的: 绕过 Anthropic 服务端对 image URL 的 robots.txt 检查
  108. (mmbiz.qpic.cn 等图床 robots disallow 会让 API 直接 400).
  109. """
  110. import hashlib
  111. from urllib.parse import urlparse
  112. from urllib.request import Request, urlopen
  113. _IMAGE_CACHE_DIR.mkdir(exist_ok=True)
  114. digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:24]
  115. ext = Path(urlparse(url).path).suffix.lower()
  116. if ext not in _MEDIA_TYPE:
  117. ext = ".png"
  118. local = _IMAGE_CACHE_DIR / f"{digest}{ext}"
  119. if local.exists() and local.stat().st_size > 0:
  120. return local
  121. req = Request(url, headers={"User-Agent": _DOWNLOAD_UA, "Accept": "image/*,*/*;q=0.8"})
  122. with urlopen(req, timeout=timeout) as resp:
  123. data = resp.read()
  124. local.write_bytes(data)
  125. return local
  126. def _append_image_blocks(blocks: List[Dict[str, Any]], images: List[str]) -> None:
  127. """循环把 images 转成 content block 加到 blocks. 单张失败不阻塞整批.
  128. base / variant driver 共用 — 保证 image 容错策略一致.
  129. """
  130. n_ok, n_fail = 0, 0
  131. for ref in images:
  132. try:
  133. blocks.append(_image_block(ref))
  134. n_ok += 1
  135. except Exception as e:
  136. n_fail += 1
  137. print(f"[image] skip {ref[:80]}... ({type(e).__name__}: {e})", flush=True)
  138. if images:
  139. print(f"[image] {n_ok}/{len(images)} 成功下载并 base64 化, {n_fail} 失败已跳过", flush=True)
  140. def _image_block(ref: str) -> Dict[str, Any]:
  141. """图 ref → Anthropic content block.
  142. URL → 客户端下载后 base64 (不再走 server-side fetch, 绕开 robots.txt 限制).
  143. 本地路径 → 直接 base64.
  144. 下载失败 (网络问题 / 404 等) → raise, 让上层决定跳过还是阻塞.
  145. """
  146. if ref.startswith(("http://", "https://")):
  147. local = _url_to_cached_path(ref)
  148. data = base64.standard_b64encode(local.read_bytes()).decode()
  149. return {
  150. "type": "image",
  151. "source": {
  152. "type": "base64",
  153. "media_type": _MEDIA_TYPE.get(local.suffix.lower(), "image/png"),
  154. "data": data,
  155. },
  156. }
  157. p = Path(ref).expanduser().resolve()
  158. if not p.exists():
  159. raise FileNotFoundError(f"image not found: {ref}")
  160. data = base64.standard_b64encode(p.read_bytes()).decode()
  161. return {
  162. "type": "image",
  163. "source": {
  164. "type": "base64",
  165. "media_type": _MEDIA_TYPE.get(p.suffix.lower(), "image/png"),
  166. "data": data,
  167. },
  168. }
  169. # ──── source JSON → 自动拉 image_url_list ────────────────────────────────────
  170. def _images_from_source(source_path: Path) -> List[str]:
  171. """从 procedure-dsl/input/*.json 抽 URL 列表, 兼容两套 schema:
  172. 1. 老 case-*-raw.json: `image_url_list: [{image_type, image_url}, ...]`,
  173. image_type=2 (封面) 排最前, 其余维持原序.
  174. 2. 新 eval_case-*.json: `images: [url_str, ...]`, 无封面信号, 原序返回.
  175. schema 错配时如果检测到疑似图字段但抽不到任何 URL, 会打 warning —
  176. multimodal pipeline 静默 0 张图比报错更难排查.
  177. """
  178. if source_path.suffix.lower() != ".json":
  179. return []
  180. try:
  181. with source_path.open(encoding="utf-8") as f:
  182. data = json.load(f)
  183. except (json.JSONDecodeError, OSError):
  184. return []
  185. if not isinstance(data, dict):
  186. return []
  187. # 新 schema (eval_case-*.json): 裸 URL 数组
  188. flat = data.get("images")
  189. if isinstance(flat, list):
  190. urls = [u for u in flat if isinstance(u, str) and u]
  191. if not urls and flat:
  192. print(f"[image] ⚠️ {source_path.name} 有 images 字段但抽不到 URL "
  193. f"(首项类型={type(flat[0]).__name__})", flush=True)
  194. return urls
  195. # 老 schema (case-*-raw.json): {image_type, image_url} dict 数组
  196. items = data.get("image_url_list")
  197. if not isinstance(items, list):
  198. return []
  199. covers, others = [], []
  200. for it in items:
  201. if not isinstance(it, dict):
  202. continue
  203. url = it.get("image_url")
  204. if not isinstance(url, str) or not url:
  205. continue
  206. (covers if it.get("image_type") == 2 else others).append(url)
  207. return covers + others
  208. # ──── 初始 user 消息 (text + 图片 blocks) ─────────────────────────────────────
  209. def _build_initial_blocks(
  210. source_ref: str, case_id: str, out_dir_arg: str, images: List[str], workdir: Path,
  211. spec_name: str = "spec",
  212. ) -> List[Dict[str, Any]]:
  213. # 全部路径用绝对路径喂给 Agent — 它训练习惯就吃绝对, 顺水推舟省心.
  214. # 用 .as_posix() 统一 forward slash, 跟 f-string 拼接的 "/..." 一致.
  215. # spec_name 由 --version 决定 (spec / spec-backup / spec-test ...), 默认 "spec".
  216. spec_dir = (workdir / spec_name).as_posix()
  217. spec_readme = (workdir / spec_name / "README.md").as_posix()
  218. spec_tools = (workdir / spec_name / "tools.md").as_posix()
  219. # --out-dir 是 outputs/ 下的相对名 (--out-dir newdir → outputs/newdir); 绝对路径原样用.
  220. _out_dir = _resolve_out_dir(out_dir_arg, workdir)
  221. case_dir = _out_dir.as_posix()
  222. scratch_dir = (_out_dir / "_scratch").as_posix()
  223. # source 路径直接用 source_ref (用户传的 positional arg). 不再从 case_id 推 —
  224. # 否则当 --out-dir 跟 source 文件名不对齐时 (e.g. --out-dir outputs/update + source=input/case-5.json),
  225. # Agent 会收到 "input/case-update-raw.json" 这种不存在路径, 浪费 10+ turn 自我探索.
  226. _src = Path(source_ref)
  227. case_input = _src.as_posix() if _src.is_absolute() else (workdir / _src).resolve().as_posix()
  228. text = f"""请按 {spec_name}/ 目录里的 SKILL 处理这个 post.
  229. ## 起手指令 (路径已给绝对值, 直接照搬, 不要改, 不要先 find 探查)
  230. 1. `Read(file_path="{spec_readme}")` — self-driven skill 的入口, 含完整 phase 加载指南 (累积式) + 自查清单 + 工具调用规则. 读完不要再 Read 它.
  231. 2. `Read(file_path="{spec_tools}")` — 外部脚本接口手册. 读完不要重读.
  232. 3. 按 README phase 加载指南**累积式**前进, 中间产物是**单个 `workflow.json` 文件**, 各 phase 都 in-place Edit 它 (不写多个中间快照):
  233. - Phase 1.1 心智模型 → `{case_dir}/understanding.md` (含 procedure 数量判断)
  234. - Phase 1.2 骨架 → **Write** `{case_dir}/workflow.json` (procedures 数组骨架)
  235. - Phase 1.3 IO 闭合 → **Edit** workflow.json 加 anchor
  236. - Phase 2A/2B/2C 归一化 → **Edit** workflow.json 加 effect/action/type/sub/form + procedures[i].type_registry
  237. - Phase 3 lint + 渲染 → 调 render-case.py + lint-case.py (不写 case_data.json, renderer 内存组装)
  238. - Phase 3 .md → Write `{case_dir}/case-{case_id}-<slug>.md`
  239. ## ❌ 重复读取禁令 (CRITICAL: ZERO REPEATED READS)
  240. 你拥有完美的长期记忆(由于 Context 累积,你读取过的所有文件内容会永远保留在你的 Context 中)。
  241. 请**绝对不要**重复读取任何文件!任何重复的 `Read` 动作都是对 Token 和回合数 Budget 的极大浪费。
  242. - **禁忌 1**:不要因为看到 spec 文档中写了 `详见 [tools.md §2]` 就去重新 `Read(file_path="{spec_tools}")`。你已经在 Turn 3 读过它了,直接检索你的记忆!
  243. - **禁忌 2**:不要因为进入了 Phase 2B,就去重新 `Read` 任何 Phase 2 的 spec 文件(如 `phase2-normalize.md`)。你已经在 Phase 2A 开始时读过它了,它就在你的记忆中,直接使用它!
  244. - 在发出任何 `Read` 指令前,必须自我核对:“我之前读过这个文件吗?”。如果读过,绝对不要再次 Read!
  245. ## 输入
  246. - case 原文: `{case_input}`
  247. schema (两种都可能):
  248. · 老格式 (case-*-raw.json): {{title, link, body_text, image_url_list:[{{image_type,image_url}}], publish_timestamp, channel_account_name}}
  249. · 新格式 (eval_case-*.json): {{title, link, body_text, images:[url_str], videos, channel, content_type, like_count, publish_timestamp, channel_content_id, ...}}
  250. 正文 (body_text) 和元数据从此文件 Read; 两种 schema 的 body_text 含义一致.
  251. - 配图: 本消息附了 {len(images)} 张图作多模态内容 (URL 抽自老格式的 image_url_list 或新格式的 images; 老格式 image_type=2 封面排最前, 新格式按原序).
  252. ## 输出目录
  253. `{case_dir}/` (这一 case 的所有产物都放这里, 不要污染其他目录)
  254. ## 读取范围 (硬约束)
  255. 你**只能** Read 以下三处 (用绝对路径 + 子路径):
  256. 1. `{spec_dir}/` 及其所有子目录 (skill 全部内容: README.md / tools.md / 各 phase 规格文件 / output/ / taxonomy/ / templates/ / tools/ — 但 tools/*.py **不要 Read 源码**, 只通过 Bash 调用; 具体子目录名以 {spec_name}/README.md 的章节地图为准, **不要脑补 part1-/part2- 这类对称目录**)
  257. 2. `{case_input}` (当前 case 的原文, 只此一个)
  258. 3. `{case_dir}/` 目录 (你自己的工作产物, 含其下 `_scratch/`)
  259. ## 长输出 dump 区 (sanctioned scratch)
  260. 如果你需要 dump 大 Bash 输出做后续分析 (e.g. `taxonomy-lookup --subtree` 输出过大想分段读, 或 `find` 结果想保留), **只写到 `{scratch_dir}/`** (runner 已预创建):
  261. ✅ `python {spec_dir}/tools/taxonomy-lookup.py --dim 实质 --subtree /表象/视觉 > {scratch_dir}/subst_visual.txt`
  262. ✅ 然后 `Read(file_path="{scratch_dir}/subst_visual.txt")` 提取需要的段
  263. ✅ 一次性小探查 / smoke test (验证某条 tool 命令、看某段数据长啥样) 也可以放 `{scratch_dir}/`, 用完即弃
  264. ❌ **不要用 Python 脚本生成 / 批量改写 `workflow.json`** — 它由你**直接 Write 骨架 + 逐字段填** 演化 (见各 phase 规格). 写 build_workflow / add_anchors / normalize 这类脚本去拼 JSON, 反而踩转义 / 控制字符坑 (LLM 拼长 JSON 极易坏), 还把本该逐 step 的语义判断埋进一次性脚本里. 几十处字段要批量改 → 用 **`spec/tools/wf-patch.py`** (path=value 清单, 工具负责安全写 JSON + 写入即校验; 见 {spec_name}/tools.md §2), 零星单处用 Edit. `_scratch/` 的 Python **只用于「dump 大 tool 输出」和「smoke test」, 不是 workflow.json 的生产线**.
  265. ❌ **不要写到项目根 `scratch/`** (那是 dev/repo 维护用的临时区, 禁区)
  266. ❌ **不要 ls / Read 项目根 `scratch/`** (别人的 dev 临时文件, 跟你无关; 你的 scratch 在上面的 `_scratch/`)
  267. **禁读区** (不要 Read 也不要 ls 这些位置): 项目根的 spec.md / design.md (旧文件), examples/ 全目录, input/ 下其他 case-*-raw.json, outputs/case-OTHER/ (其他 case 目录), bin/ 全目录 (已废弃), **项目根 `scratch/`** (跟你的 `_scratch/` 不是一回事 — 上面 ✅ 那个 `_scratch/` 作用类似 outputs 目录下的 `_scratch/`).
  268. ## 阶段三流程 (脚本组装, 不写 case_data.json)
  269. 新工作流: Phase 2 已经把所有结构化数据 in-place Edit 进 `{case_dir}/workflow.json`. Phase 3 直接调 2 个脚本命令, **不需要写 case_data.json** (renderer 在内存把 workflow + source-input + page_title + case_id 组装成 case_data 喂给 build_html).
  270. ```bash
  271. # Step A: lint + 自动 record 新 type 到 type_suggestions.md
  272. python {spec_dir}/tools/lint-case.py --workflow {case_dir}/workflow.json --case-id {case_id}
  273. # stdout 报 "type 完整性: N 个提示" 时, 回 Phase 2 Edit workflow.json 补 procedures[i].type_registry, 重跑 lint
  274. # stdout 报 "已 record M 条新 type" 时, 表示 {spec_name}/taxonomy/type_suggestions.md 已自动同步
  275. # Step B: 渲染 HTML (--source-input 必带, --page-title --case-id 必带)
  276. python {spec_dir}/tools/render-case.py \
  277. --workflow {case_dir}/workflow.json \
  278. --source-input {case_input} \
  279. --page-title "Case {case_id} · <主题>" \
  280. --case-id {case_id} \
  281. --out {case_dir}/case-{case_id}-<slug>.html
  282. # Step C: Write {case_dir}/case-{case_id}-<slug>.md (DSL 文本版, 按 {spec_name}/output/md-structure.md §11 结构)
  283. ```
  284. **`--source-input` 行为**: renderer 自动从 raw 抽 body_text + 封面图 + 图集兜底填到 case_data.source (内存里). 微信公众号长文走 inline 图 + 封面; 小红书短文走 body + "--- 附图 ---" 末尾追加非封面图. 你**不必手工**复制 raw.body_text — 完全交给 renderer.
  285. `workflow.json` 的契约见 [{spec_name}/output/case-data.schema.json]({spec_name}/output/case-data.schema.json) 里 Procedure 的 definition (顶层 `{{procedures: [{{id, name, purpose, declarations, type_registry?, steps, return_row?}}]}}`). **不要参考其他 `outputs/case-*/` 下任何文件**.
  286. ## 其他约束
  287. - 推断补全用 `inferred: true` + `inferred_reason` 标在 IO item 上, 不要静默插入.
  288. - 完成后用一段话总结: 工序梗概 + 输出文件路径 + 你对 DSL 的关键发现.
  289. """
  290. blocks: List[Dict[str, Any]] = [{"type": "text", "text": text}]
  291. _append_image_blocks(blocks, images)
  292. return blocks
  293. # ──── Trace 写盘 (outputs/case-N/_trace.md) ───────────────────────────────────
  294. # 每次 run 把 turn / tool_use / text / result 写到一份 markdown 流水, 实时 append.
  295. # Ctrl-C 后还能离线 review Agent 走过哪些 turn / 读了哪些文件 / 调了哪些 Bash.
  296. def _fmt_tool_input(name: str, inp: Any) -> str:
  297. """tool_use input → 人读 1 行摘要."""
  298. if not isinstance(inp, dict):
  299. return f"`{repr(inp)[:200]}`"
  300. if name == "Read":
  301. suffix = ""
  302. if inp.get("offset") or inp.get("limit"):
  303. suffix = f" (offset={inp.get('offset', 0)}, limit={inp.get('limit', '-')})"
  304. return f"`{inp.get('file_path', '')}`{suffix}"
  305. if name == "Write":
  306. return f"`{inp.get('file_path', '')}` ({len(inp.get('content', '')):,} chars)"
  307. if name == "Edit":
  308. return f"`{inp.get('file_path', '')}`"
  309. if name == "Bash":
  310. cmd = inp.get("command", "")
  311. if len(cmd) > 200:
  312. cmd = cmd[:200] + "..."
  313. return f"`{cmd}`"
  314. if name == "Grep":
  315. return f"pattern=`{inp.get('pattern', '')}` path=`{inp.get('path', '')}`"
  316. if name == "Glob":
  317. return f"pattern=`{inp.get('pattern', '')}`"
  318. if name in ("Task", "Agent"):
  319. return f"`{inp.get('description', '')}` [subagent={inp.get('subagent_type', '?')}]"
  320. preview = str(inp)
  321. return f"`{preview[:200] + '...' if len(preview) > 200 else preview}`"
  322. def _trace_banner(trace_path: Path, args, resume_sid, source_path, n_images: int) -> None:
  323. """session 起手 banner. Append 模式 — 历次 run 累积在同一 _trace.md."""
  324. trace_path.parent.mkdir(parents=True, exist_ok=True)
  325. now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  326. if resume_sid:
  327. chunk = (
  328. f"\n\n---\n\n## ▶ Resume @ {now}\n\n"
  329. f"恢复 session `{resume_sid[:8]}` (case-{args.case_id})\n"
  330. )
  331. else:
  332. chunk = (
  333. f"\n\n---\n\n## ▶ Fresh @ {now}\n\n"
  334. f"- case: `{args.case_id}`\n"
  335. f"- source: `{source_path}`\n"
  336. f"- spec: `{getattr(args, 'spec_name', 'spec')}`\n"
  337. f"- images: `{n_images}`\n"
  338. f"- model: `{args.model}`\n"
  339. f"- max_turns: `{args.max_turns}`\n"
  340. )
  341. with trace_path.open("a", encoding="utf-8") as f:
  342. f.write(chunk)
  343. def _trace_init(trace_path: Path, sid: str, data: Dict[str, Any]) -> None:
  344. """SDK init → 写 session_id / model 到 trace."""
  345. line = (
  346. f"\n_session={sid[:8]} · model={data.get('model')!r} "
  347. f"· apiKeySource={data.get('apiKeySource')!r}_\n"
  348. )
  349. with trace_path.open("a", encoding="utf-8") as f:
  350. f.write(line)
  351. def _trace_turn(trace_path: Path, turn: int, msg: Any) -> None:
  352. """每个 AssistantMessage → text + tool_use 摘要追加到 trace."""
  353. now = datetime.now().strftime("%H:%M:%S")
  354. parts = [f"\n\n### Turn {turn} · {now}\n"]
  355. texts: List[str] = []
  356. tools: List[tuple] = []
  357. for block in msg.content:
  358. if hasattr(block, "text"):
  359. t = block.text.strip()
  360. if t:
  361. texts.append(t)
  362. elif hasattr(block, "name") and hasattr(block, "input"):
  363. tools.append((block.name, block.input))
  364. # thinking blocks 跳过
  365. if texts:
  366. for t in texts:
  367. for line in t.split("\n"):
  368. parts.append(f"> {line}\n" if line else ">\n")
  369. parts.append("\n")
  370. if tools:
  371. for name, inp in tools:
  372. parts.append(f"- `{name}` — {_fmt_tool_input(name, inp)}\n")
  373. with trace_path.open("a", encoding="utf-8") as f:
  374. f.write("".join(parts))
  375. def _trace_result(trace_path: Path, msg: Any, elapsed: float,
  376. usage: Dict[str, Any], est_cost: float) -> None:
  377. """ResultMessage → final summary 追加到 trace."""
  378. now = datetime.now().strftime("%H:%M:%S")
  379. sdk_cost = msg.total_cost_usd
  380. sdk_cost_str = f"${sdk_cost:.4f}" if sdk_cost is not None else "None (OAuth Max)"
  381. chunk = (
  382. f"\n\n### ◀ Result · {now}\n\n"
  383. f"- subtype: `{msg.subtype}` · is_error: `{msg.is_error}`\n"
  384. f"- num_turns: `{msg.num_turns}` · duration: `{msg.duration_ms}ms` · wall: `{elapsed:.1f}s`\n"
  385. f"- tokens: in={usage.get('input_tokens', 0):,} "
  386. f"out={usage.get('output_tokens', 0):,} "
  387. f"cache_w={usage.get('cache_creation_input_tokens', 0):,} "
  388. f"cache_r={usage.get('cache_read_input_tokens', 0):,}\n"
  389. f"- cost: sdk={sdk_cost_str}, est_if_api=${est_cost:.4f}\n"
  390. )
  391. with trace_path.open("a", encoding="utf-8") as f:
  392. f.write(chunk)
  393. # ──── 主流程: 跑 ClaudeSDKClient ──────────────────────────────────────────────
  394. async def run(args: argparse.Namespace) -> None:
  395. from claude_agent_sdk import (
  396. AssistantMessage,
  397. ClaudeAgentOptions,
  398. ClaudeSDKClient,
  399. ClaudeSDKError,
  400. RateLimitEvent,
  401. ResultMessage,
  402. TextBlock,
  403. AgentDefinition,
  404. )
  405. workdir = Path(args.workdir or DSL_ROOT).resolve()
  406. if not workdir.exists():
  407. sys.exit(f"❌ workdir not found: {workdir}")
  408. source_path = Path(args.source).expanduser().resolve()
  409. if not source_path.exists():
  410. sys.exit(f"❌ source not found: {source_path}")
  411. auto_imgs = _images_from_source(source_path)
  412. extra_imgs = args.extra_image or []
  413. images = auto_imgs + extra_imgs
  414. # 给 Agent 的路径优先用 workdir 相对路径 (它的 cwd 就是 workdir),
  415. # 不在 workdir 内才回落到绝对路径.
  416. try:
  417. source_for_agent = source_path.relative_to(workdir).as_posix()
  418. except ValueError:
  419. source_for_agent = str(source_path)
  420. # --out-dir 是 outputs/ 下的相对名 (--out-dir newdir → outputs/newdir); 绝对路径原样用.
  421. out_dir = _resolve_out_dir(args.out_dir, workdir)
  422. out_dir.mkdir(parents=True, exist_ok=True)
  423. sid_file = out_dir / ".session_id"
  424. # 预创建 sanctioned scratch 目录, 让 Agent 看到就在那, 不用犹豫能不能 mkdir
  425. (out_dir / "_scratch").mkdir(exist_ok=True)
  426. resume_sid = None
  427. if args.resume:
  428. if not sid_file.exists():
  429. sys.exit(f"❌ --resume 但未找到 {sid_file}; 先正常跑一次建立 session")
  430. resume_sid = sid_file.read_text(encoding="utf-8").strip() or None
  431. if not resume_sid:
  432. sys.exit(f"❌ {sid_file} 为空, 无法 resume")
  433. try:
  434. rel_out = out_dir.relative_to(workdir).as_posix()
  435. except ValueError:
  436. rel_out = out_dir.as_posix()
  437. if resume_sid:
  438. # resume 时不重发图和原始指令 (历史里都有); 只给一段 "接着做" 增量.
  439. # 关键: 提醒 Agent 用户可能改过中间产物, 必须 Read 当前磁盘版本.
  440. blocks = [{"type": "text", "text": (
  441. f"上次中断了, 接续做 case-{args.case_id} 的提取流程.\n\n"
  442. f"先 ls {rel_out}/ 看当前已落盘哪些产物;\n"
  443. f"用户可能在中断期间编辑过任何中间产物 (understanding.md / workflow.json) "
  444. f"或改过 spec/ 内任何文件 — 务必 Read 这些**当前磁盘版本**, "
  445. f"不要凭之前记忆继续. 如发现明显人工修订痕迹, 沿用用户改过的版本.\n\n"
  446. f"⚠️【重要禁令与强制要求】:如果流程进行到 Phase 2(归一化与分类匹配),主 Agent **绝对禁止**手动调用 taxonomy-lookup.py 查询或手动决策!你必须强制阅读最新的 `spec/extraction/phase2-normalize.md` 规范,通过运行 `prepare-subtask.py` 生成物理任务切片,然后调用 `Agent`(或 `Task`)工具将任务分别分发给预定义好的 `phase-2a-normalizer` 和 `phase-2b-matcher` 子 Agent 并行协作执行!"
  447. )}]
  448. else:
  449. blocks = _build_initial_blocks(source_for_agent, args.case_id, args.out_dir, images, workdir, args.spec_name)
  450. print(f"[setup] workdir = {workdir}")
  451. print(f"[setup] version = {args.version if args.version else '(default)'}")
  452. print(f"[setup] spec dir = {args.spec_name}/")
  453. print(f"[setup] source = {source_path}")
  454. print(f"[setup] → as agent = {source_for_agent}")
  455. print(f"[setup] case_id = {args.case_id}")
  456. print(f"[setup] output dir = {out_dir}")
  457. print(f"[setup] auto images = {len(auto_imgs)} (from image_url_list)"
  458. if not resume_sid else f"[setup] auto images = -- (resume 跳过)")
  459. print(f"[setup] extra images = {len(extra_imgs)} {extra_imgs if extra_imgs else ''}"
  460. if not resume_sid else f"[setup] extra images = -- (resume 跳过)")
  461. print(f"[setup] model = {args.model}")
  462. print(f"[setup] max_turns = {args.max_turns}")
  463. if resume_sid:
  464. print(f"[setup] resume = {resume_sid[:8]} (跳过初始 prompt + 图)")
  465. else:
  466. print(f"[setup] resume = no (fresh start)")
  467. print(flush=True)
  468. # 起手写 trace banner. 之后每 turn / result 都会 append 到这个 _trace.md.
  469. trace_path = out_dir / "_trace.md"
  470. _trace_banner(trace_path, args, resume_sid, source_path, len(images))
  471. print(f"[trace] -> {trace_path}", flush=True)
  472. stderr_buf: List[str] = []
  473. def _capture_stderr(line: str) -> None:
  474. if line:
  475. stderr_buf.append(line)
  476. print(f"[stderr] {line}", flush=True)
  477. agents = {
  478. "phase-2a-normalizer": AgentDefinition(
  479. description="Expert in Phase 2A (effect/action/type normalization). Use this agent to read task_2a.json, normalize action/effect/type against spec trees, manage procedure-level type_registry, and generate outputs/case-N/_scratch/patch_2a.json.",
  480. prompt="""You are a dedicated Phase 2A normalization sub-agent.
  481. Your goal is to process the inputs and outputs of a workflow for effect, action, and type normalization:
  482. 1. Read the outputs/case-N/_scratch/task_2a.json file to get the steps and IO variables.
  483. 2. Normalize every step's `effect` and `action` against the taxonomy specs in `spec/taxonomy/effect.json` and `spec/taxonomy/action.json`.
  484. 3. Normalize every IO variable's `type` against `spec/taxonomy/type.json`. If a custom type is used, register it in the procedure's `type_registry` with extends and description.
  485. 4. Output a standard `patch_2a.json` JSON file under outputs/case-N/_scratch/.
  486. IMPORTANT: The format of `patch_2a.json` MUST be a flat JSON array of objects, where each object has a "path" and a "value" key (exactly conforming to the `wf-patch.py` tool contract).
  487. Example format:
  488. [
  489. {"path": "p1.s1.effect", "value": "预处理"},
  490. {"path": "p1.s1.action", "value": "提取/化学提取/反推"},
  491. {"path": "p1.s1.inputs[0].type", "value": "工具选型标准"},
  492. {"path": "p1.type_registry.工具配置.extends", "value": "评语"},
  493. {"path": "p1.type_registry.工具配置.desc", "value": "工具选型依据..."}
  494. ]
  495. Do not output raw dictionary structure or any other nesting. Do not touch or modify other files.""",
  496. tools=["Read", "Grep", "Glob", "Write", "Edit", "Bash"],
  497. model="sonnet",
  498. ),
  499. "phase-2b-matcher": AgentDefinition(
  500. description="Expert in Phase 2B (substance/form taxonomy matching). Use this agent to read task_2b.json, invoke taxonomy-lookup.py to query substance and form paths for each variable, and generate outputs/case-N/_scratch/patch_2b.json.",
  501. prompt="""You are a dedicated Phase 2B taxonomy matching sub-agent.
  502. Your goal is to query and match the substance and form for each workflow IO variable:
  503. 1. Read the outputs/case-N/_scratch/task_2b.json file to get the variables to match.
  504. 2. For each variable, run `python spec/tools/taxonomy-lookup.py --dim 实质 --match "..."` and `--dim 形式 --match "..."` to search for the most precise taxonomy paths matching the variable's value, name, and related_images.
  505. 3. Output a standard `patch_2b.json` JSON file under outputs/case-N/_scratch/.
  506. IMPORTANT: The format of `patch_2b.json` MUST be a flat JSON array of objects, where each object has a "path" and a "value" key (exactly conforming to the `wf-patch.py` tool contract). Substance and form values can be single string paths, multiple paths separated by ' + ', or JSON arrays of strings for multi-path matching.
  507. Example format:
  508. [
  509. {"path": "p1.s1.inputs[0].substance", "value": "/理念/知识/思想/概念范畴/性质属性/功能效用"},
  510. {"path": "p1.s1.inputs[0].form", "value": "/呈现/视觉/视觉制作/构图编排/版面设计/版面结构"},
  511. {"path": "p1.s2.inputs[0].substance", "value": ["/理念/知识/商业/前沿技术/AI智能/AI应用", "/理念/知识/思想"]}
  512. ]
  513. Do not output raw dictionary structure or any other nesting. Do not touch or modify other files.""",
  514. tools=["Read", "Grep", "Glob", "Write", "Edit", "Bash"],
  515. model="sonnet",
  516. )
  517. }
  518. options = ClaudeAgentOptions(
  519. model=args.model,
  520. cwd=str(workdir),
  521. resume=resume_sid,
  522. # ⚠️ 支持旧版 "Task" 与新版 "Agent" 标识符以确保在所有 Claude Code / SDK 版本中均可激活子 agent
  523. allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep", "Task", "Agent"],
  524. agents=agents,
  525. max_turns=args.max_turns,
  526. permission_mode="bypassPermissions",
  527. setting_sources=[],
  528. env={
  529. "ANTHROPIC_API_KEY": "",
  530. "ANTHROPIC_BASE_URL": "",
  531. "ANTHROPIC_AUTH_TOKEN": "",
  532. },
  533. stderr=_capture_stderr,
  534. )
  535. turn = 0
  536. usage: Dict[str, Any] = {}
  537. t0 = time.time()
  538. try:
  539. async with ClaudeSDKClient(options=options) as client:
  540. async def _input_stream():
  541. yield {
  542. "type": "user",
  543. "message": {"role": "user", "content": blocks},
  544. "parent_tool_use_id": None,
  545. "session_id": "default",
  546. }
  547. await client.query(_input_stream())
  548. async for msg in client.receive_response():
  549. if isinstance(msg, AssistantMessage):
  550. turn += 1
  551. for block in msg.content:
  552. if isinstance(block, TextBlock):
  553. print(f"\n[turn {turn} · text]\n{block.text}\n", flush=True)
  554. elif hasattr(block, "name") and hasattr(block, "input"):
  555. preview = str(block.input)
  556. if len(preview) > 200:
  557. preview = preview[:200] + "..."
  558. print(f"[turn {turn} · tool_use] {block.name}({preview})", flush=True)
  559. elif hasattr(block, "thinking"):
  560. pass # thinking 太长, 跳过
  561. else:
  562. print(f"[turn {turn} · {type(block).__name__}] {block!r}", flush=True)
  563. _trace_turn(trace_path, turn, msg)
  564. elif isinstance(msg, ResultMessage):
  565. if msg.usage:
  566. usage = dict(msg.usage)
  567. elapsed = time.time() - t0
  568. sdk_cost = msg.total_cost_usd
  569. sdk_cost_str = (
  570. f"${sdk_cost:.4f}" if sdk_cost is not None
  571. else "None (OAuth Max — not metered as $)"
  572. )
  573. est_cost = _estimate_cost_usd(usage)
  574. print(
  575. f"\n[result] subtype={msg.subtype} is_error={msg.is_error} "
  576. f"turns={msg.num_turns} duration={msg.duration_ms}ms wall={elapsed:.1f}s\n"
  577. f" tokens: in={usage.get('input_tokens', 0):,} "
  578. f"out={usage.get('output_tokens', 0):,} "
  579. f"cache_w={usage.get('cache_creation_input_tokens', 0):,} "
  580. f"cache_r={usage.get('cache_read_input_tokens', 0):,}\n"
  581. f" cost : sdk={sdk_cost_str}, est_if_api=${est_cost:.4f} "
  582. f"(Sonnet 4.6 价目)",
  583. flush=True,
  584. )
  585. _trace_result(trace_path, msg, elapsed, usage, est_cost)
  586. if msg.is_error:
  587. print(f"❌ result is_error=True", file=sys.stderr)
  588. sys.exit(2)
  589. elif isinstance(msg, RateLimitEvent):
  590. info = getattr(msg, "rate_limit_info", None)
  591. info_status = getattr(info, "status", None) if info else None
  592. if info_status == "allowed_warning":
  593. print(f"⚠️ [rate_limit_warning] Max 订阅 5h 窗口余额较少, `claude /status` 看余量", file=sys.stderr)
  594. elif info_status and info_status not in ("allowed", "allowed_warning"):
  595. print(f"❌ [rate_limit_blocked] {info_status!r} — Max 订阅 5h 窗口已耗尽, `claude /status` 看余量", file=sys.stderr)
  596. sys.exit(3)
  597. else:
  598. name = type(msg).__name__
  599. if name == "SystemMessage":
  600. data = getattr(msg, "data", {}) or {}
  601. subtype = getattr(msg, "subtype", "?")
  602. if subtype == "init":
  603. sid = data.get('session_id', '') or ''
  604. if sid:
  605. # 早写: 一拿到 sid 就落盘, Ctrl-C 再早也能 resume
  606. sid_file.write_text(sid, encoding="utf-8")
  607. _trace_init(trace_path, sid, data)
  608. print(
  609. f"[init] model={data.get('model')!r} "
  610. f"apiKeySource={data.get('apiKeySource')!r} "
  611. f"session={sid[:8]}",
  612. flush=True,
  613. )
  614. except ClaudeSDKError as e:
  615. tail = "\n".join(stderr_buf[-20:])
  616. print(
  617. f"❌ SDK error: {type(e).__name__}: {e}\n"
  618. f"--- CLI stderr (last 20 lines) ---\n{tail}",
  619. file=sys.stderr,
  620. )
  621. sys.exit(1)
  622. # ──── CLI ─────────────────────────────────────────────────────────────────────
  623. def _parse_args() -> argparse.Namespace:
  624. p = argparse.ArgumentParser(
  625. description="跑 procedure-dsl 提取流程 (Claude Agent SDK, OAuth Max)",
  626. formatter_class=argparse.RawDescriptionHelpFormatter,
  627. epilog=__doc__,
  628. )
  629. p.add_argument("source", help="原始 post 文件 (input/case-N-raw.json; 也接受 .md/.txt)")
  630. p.add_argument("--extra-image", action="append", default=[],
  631. help="额外配图: 本地路径 or http(s) URL. 加在 image_url_list "
  632. "自动抽出的图后面. 可多次传.")
  633. p.add_argument("--out-dir", type=str, required=True,
  634. help="输出工作目录名, 落在 outputs/ 下. e.g. --out-dir case-5-newflow → 实际 outputs/case-5-newflow/ . "
  635. "Agent 所有产物 (understanding.md / workflow.json / case-X.html / .md) 都落在这. "
  636. "case_id 自动从 basename 推 (e.g. --out-dir case-5-newflow → case_id='5-newflow'). "
  637. "已带 outputs/ 前缀会被容忍 (不重复嵌套); 传绝对路径则原样用.")
  638. p.add_argument("--model", default="claude-sonnet-4-6",
  639. help="Claude 模型名 (default: claude-sonnet-4-6)")
  640. p.add_argument("--workdir", default=None,
  641. help=f"SDK cwd. 默认 {DSL_ROOT} (即本脚本所在 procedure-dsl/)")
  642. p.add_argument("--version", default=None, metavar="SUFFIX",
  643. help="spec 版本: 跑 workdir 下的 spec-<SUFFIX>/ 目录. "
  644. "e.g. --version backup → spec-backup/, --version test → spec-test/. "
  645. "不传则默认 spec/.")
  646. p.add_argument("--max-turns", type=int, default=300,
  647. help="Agent 最大回合数 (default: 300, 跑完整三阶段 + lint 留足余量)")
  648. p.add_argument("--resume", action="store_true",
  649. help="恢复中断的 session: 从 outputs/case-N/.session_id 读 sid, "
  650. "Agent 拿上次完整历史接着跑. 中断期间可手改任何中间产物, "
  651. "Agent 会重新 Read 磁盘版本而不是凭记忆.")
  652. p.add_argument("--max-retries", type=int, default=3,
  653. help="Stream idle / 临时错误自动 --resume 重试次数 (default: 3). "
  654. "退避 10s/20s/40s, 不重试 RateLimit (exit 3) 和 Ctrl-C.")
  655. return p.parse_args()
  656. def main() -> None:
  657. # Windows 默认控制台编码 (cp1252/gbk) 撑不住 spec.md / Agent 输出里的中文 +
  658. # emoji, 第一次打印就 UnicodeEncodeError. 在 main 入口统一切到 UTF-8.
  659. for stream in (sys.stdout, sys.stderr):
  660. if hasattr(stream, "reconfigure"):
  661. stream.reconfigure(encoding="utf-8", errors="replace")
  662. logging.basicConfig(level=logging.WARNING)
  663. args = _parse_args()
  664. # 注入派生属性: case_id 自动从 --out-dir basename 算 (去掉 case- 前缀).
  665. # 下游 prompt / banner / type_suggestions record 都用这个 args.case_id.
  666. args.case_id = _derive_case_id(args.out_dir)
  667. # spec 目录名: --version backup → "spec-backup", 不传 → "spec".
  668. args.spec_name = "spec" if not args.version else f"spec-{args.version}"
  669. # ──── 自动重试循环 ──────────────────────────────────────────────────────
  670. # exit code 约定:
  671. # 0 = 成功
  672. # 1 = ClaudeSDKError (含 setup 错, 一般非 transient, 不重试)
  673. # 2 = ResultMessage is_error=True (含 stream idle timeout, **可重试**)
  674. # 3 = RateLimitEvent (配额耗尽, 不重试 — 等 5h window 滑动)
  675. # 130 = KeyboardInterrupt (用户主动中断, 不重试)
  676. # 重试退避: 10s → 20s → 40s, 都在 prompt cache TTL (5min) 内
  677. max_retries = args.max_retries
  678. workdir_check = Path(args.workdir or DSL_ROOT).resolve()
  679. out_dir_check = Path(args.out_dir)
  680. sid_file_check = (out_dir_check if out_dir_check.is_absolute() else (workdir_check / out_dir_check)).resolve() / ".session_id"
  681. # --version 选定的 spec 目录必须存在. 在进重试循环前 fail-fast:
  682. # 注意 run() 里 sys.exit(字符串) 的消息会被下面 except SystemExit 吞掉,
  683. # 所以这里在 main 早校验, 直接 print 到 stderr 再 exit(1).
  684. spec_dir_check = workdir_check / args.spec_name
  685. if not spec_dir_check.is_dir():
  686. avail = sorted(p.name for p in workdir_check.glob("spec*") if p.is_dir())
  687. print(
  688. f"❌ spec dir not found: {spec_dir_check}\n"
  689. f" (--version {args.version!r} → 期望目录 '{args.spec_name}/')\n"
  690. f" 可用版本: {avail}",
  691. file=sys.stderr,
  692. )
  693. sys.exit(1)
  694. for attempt in range(max_retries + 1):
  695. try:
  696. asyncio.run(run(args))
  697. return # 成功完成
  698. except KeyboardInterrupt:
  699. # session_id 在 init 时就落盘了, 已写到磁盘的产物全部保留.
  700. # 加 --resume 重启即可接着跑.
  701. print(
  702. f"\n⚠️ 中断 (Ctrl-C). {args.out_dir}/ 产物已保留.\n"
  703. f" 恢复: python run_procedure_dsl.py {args.source} "
  704. f"--out-dir {args.out_dir} --resume",
  705. file=sys.stderr,
  706. )
  707. sys.exit(130)
  708. except SystemExit as e:
  709. code = e.code if isinstance(e.code, int) else (1 if e.code else 0)
  710. # exit 2 = is_error (stream timeout 之类 transient), 可重试
  711. if code == 2 and attempt < max_retries:
  712. wait_s = 10 * (2 ** attempt) # 10s / 20s / 40s
  713. print(
  714. f"\n⚠️ attempt {attempt+1}/{max_retries+1} exit={code} "
  715. f"(可能 stream idle timeout). 等 {wait_s}s 后 --resume 重试...",
  716. file=sys.stderr,
  717. )
  718. time.sleep(wait_s)
  719. # 强制 --resume (前提是 session_id 已落盘 — init 阶段就写了, 通常都在)
  720. if sid_file_check.exists():
  721. args.resume = True
  722. else:
  723. print(f"⚠️ {sid_file_check} 不存在, fresh restart 而非 resume",
  724. file=sys.stderr)
  725. continue
  726. # 不重试: exit 0 (成功) / exit 1 (SDK 错) / exit 3 (RateLimit) / 用尽 retries
  727. sys.exit(code)
  728. # 用尽 max_retries 后还到这里 (按 for-else 语义实际不会, 但保险)
  729. print(f"\n❌ 用尽 {max_retries} 次重试.", file=sys.stderr)
  730. sys.exit(2)
  731. if __name__ == "__main__":
  732. main()