run_cyber.py 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899
  1. #!/usr/bin/env python3
  2. """
  3. run_cyber.py — run_procedure_dsl.py 的 Cyber Agent 移植版 (POC)。
  4. 与 run_procedure_dsl.py 的唯一区别是**执行引擎**:
  5. - run_procedure_dsl.py: Claude Agent SDK (ClaudeSDKClient) → 走 ~/.claude OAuth Max,
  6. 只能跑 Anthropic 协议端点。
  7. - run_cyber.py: 本仓库自研 AgentRunner (agent/core/runner.py) → 多 Provider。
  8. 本 POC 默认走 OpenRouter (create_openrouter_llm_call), 一个 provider 通打
  9. GPT / Gemini / Qwen / DeepSeek / Claude 全家, 换模型只改 --model 字符串。
  10. 复用 run_procedure_dsl.py 的:
  11. - 起手 prompt 全文 (_build_initial_blocks 的 text 块) —— 三阶段指令一字不改。
  12. - 图片抽取 (_images_from_source) + 客户端下载缓存 (_url_to_cached_path)。
  13. 图片块从 Anthropic base64 格式转成 OpenRouter 要的 OpenAI `image_url` data-URL 格式。
  14. 单 Agent 全程跑 (与 spec 对齐, 见 spec/tools.md §7): Phase 2 (归类标注) 由主 Agent
  15. 自己一趟做完, 不再分发 phase-2a/2b 子 Agent。presets_cyber.json 只保留 main preset;
  16. exclude_tools 关掉 agent/evaluate 两个分发工具, 防弱模型自作主张去 delegate。
  17. 用法 (与 run_procedure_dsl.py 对齐):
  18. python run_cyber.py input/case-2-raw.json --out-dir case-2-cyber
  19. python run_cyber.py input/case-2-raw.json --out-dir case-2-cyber \
  20. --model openai/gpt-4o
  21. # 中断后续跑 (从 outputs/<out-dir>/.trace_id 读 trace 接着跑):
  22. python run_cyber.py input/case-2-raw.json --out-dir case-2-cyber --resume
  23. ⚠️ POC 已知缺口 (非 Claude 模型上需逐步调):
  24. - 起手 prompt 与 spec/ 里写的是 Claude 工具名 (Read/Write/Bash)。Cyber 实际工具是
  25. read_file/write_file/edit_file/bash_command。下方 _CYBER_RUNTIME_NOTE 给了映射, 但
  26. spec 文档内 `详见 Read(...)` 这类示例仍是 Claude 名 —— 弱模型可能被带偏, 需观察 trace。
  27. - edit_file 在非 Claude 模型上的 exact-match 命中率不如 Claude, workflow.json 反复 Edit
  28. 可能卡壳。先拿单 case smoke test, 别直接上量。
  29. """
  30. import argparse
  31. import asyncio
  32. import base64
  33. import httpx
  34. import importlib.util
  35. import json
  36. import logging
  37. import os
  38. import sys
  39. import time
  40. from datetime import datetime
  41. from pathlib import Path
  42. from typing import Any, Dict, List
  43. # run_cyber.py → procedure-dsl/
  44. DSL_ROOT = Path(__file__).resolve().parent
  45. def _find_repo_root(start: Path) -> Path:
  46. """向上找含 pyproject.toml 的目录 (cyber-agent 仓库根), 用于 sys.path 兜底。"""
  47. for p in [start, *start.parents]:
  48. if (p / "pyproject.toml").exists():
  49. return p
  50. return start
  51. REPO_ROOT = _find_repo_root(DSL_ROOT)
  52. for _p in (str(REPO_ROOT), str(DSL_ROOT)):
  53. if _p not in sys.path:
  54. sys.path.insert(0, _p)
  55. # 技能本地「计划」内置工具 (plan_procedures): import 即把它注册进全局工具表 (groups=["core"]),
  56. # 主 Agent 因 tool_groups 含 core 而能看到它。Phase 1 第一步让 LLM 调用它做 understanding +
  57. # 自动生成 workflow.json 骨架。run_cyber 仅做注册 + 注入原文上下文, 业务逻辑全在 plan_tool.py。
  58. import plan_tool # noqa: E402 (必须在 sys.path 设好之后)
  59. # 阶段文件 → 阶段标识 (监听 read_file 调用判断 agent 有没有读对应阶段规格)
  60. _PHASE_FILES = {"phase1-skeleton": "phase1", "phase2-normalize": "phase2", "phase3-finalize": "phase3"}
  61. def _phase_read_gaps(out_dir: Path, read_phase: set) -> List[str]:
  62. """检查 agent 有没有「做了某阶段的活却没读那个阶段的规格文件」(弱模型惯犯, 导致格式/规则全靠瞎猜)。
  63. 判「做了某阶段」: Phase 2 = 填了 effect/action/intent; Phase 3 = 出了 HTML。
  64. 做了却没 read_file 对应文件 → 报出来, 续跑时逼它先读再修。
  65. """
  66. gaps: List[str] = []
  67. wf = out_dir / "workflow.json"
  68. if not wf.exists():
  69. return gaps
  70. try:
  71. d = json.loads(wf.read_text(encoding="utf-8"))
  72. except Exception:
  73. return gaps
  74. did_p1 = any(p.get("steps") for p in d.get("procedures", []))
  75. did_p2 = any((s.get("effect") or s.get("action") or s.get("intent"))
  76. for p in d.get("procedures", []) for s in (p.get("steps") or []) if isinstance(s, dict))
  77. did_p3 = bool(list(out_dir.glob("*.html")))
  78. if did_p1 and "phase1" not in read_phase:
  79. gaps.append("你建了 workflow(Phase 1)却**没 read_file** spec/extraction/phase1-skeleton.md —— "
  80. "先读它(字段填法、@quote 用法、IO 校验要求), 再对照检查你的 Phase 1 输出。")
  81. if did_p2 and "phase2" not in read_phase:
  82. gaps.append("你做了 Phase 2(填了 effect/action/intent)却**没 read_file** "
  83. "spec/extraction/phase2-normalize.md —— 先读它(尤其 §5 intent 的 {in-type:}/{out-type:} "
  84. "标记格式、词表归类规则), 再对照修正你的 Phase 2 输出。")
  85. if did_p3 and "phase3" not in read_phase:
  86. gaps.append("你做了 Phase 3(出了 HTML)却**没 read_file** "
  87. "spec/extraction/phase3-finalize.md —— 先读它的收尾检查清单再确认。")
  88. return gaps
  89. def _completion_gaps(out_dir: Path) -> List[str]:
  90. """跑完后查 workflow 是否真做完了 (防弱模型吐空消息提前自停)。返回未完成项清单, 空=完成。
  91. 判据: ① 每步填了 effect+action(Phase 2 归类完); ② intent 用了 {in-type:}/{out-type:} 标记格式
  92. (Phase 2 §5, 渲染成彩色类型胶囊); ③ 出了 HTML(Phase 3 渲染完)。
  93. """
  94. gaps: List[str] = []
  95. wf = out_dir / "workflow.json"
  96. if not wf.exists():
  97. return ["workflow.json 还没建 (Phase 1 没做完)"]
  98. try:
  99. d = json.loads(wf.read_text(encoding="utf-8"))
  100. except Exception:
  101. return ["workflow.json 不是合法 JSON (修好再继续)"]
  102. _MARKERS = ("in-type:", "out-type:", "act:", "via:", "effect:")
  103. missing, intent_bad = [], []
  104. for p in d.get("procedures", []):
  105. for s in p.get("steps", []):
  106. if not isinstance(s, dict) or s.get("kind") == "block":
  107. continue # 控制块不要求 effect/action
  108. sid = f"{p.get('id')}.{s.get('id')}"
  109. if not (s.get("effect") or "").strip() or not (s.get("action") or "").strip():
  110. missing.append(sid)
  111. # intent 标记格式: 有 IO 的步, intent 必须带 {in-type:}/{out-type:} 等标记
  112. has_io = bool(s.get("inputs") or s.get("outputs"))
  113. intent = (s.get("intent") or "").strip()
  114. if has_io and (not intent or "{" not in intent or not any(m in intent for m in _MARKERS)):
  115. intent_bad.append(sid)
  116. if missing:
  117. gaps.append(f"{len(missing)} 个步骤缺 effect/action (Phase 2 没做完): "
  118. f"{', '.join(missing[:8])}{' …' if len(missing) > 8 else ''}")
  119. if intent_bad:
  120. gaps.append(f"{len(intent_bad)} 个步骤的 intent 没用标记格式 (Phase 2 §5: 写成带 "
  121. f"{{in-type:X}}/{{out-type:Y}}/{{act:Z}} 的句子, 如 `{{act:元素生成}}从{{in-type:提示词}}得到{{out-type:场景图}}`): "
  122. f"{', '.join(intent_bad[:8])}{' …' if len(intent_bad) > 8 else ''}")
  123. if not list(out_dir.glob("*.html")):
  124. gaps.append("还没渲染出 HTML (Phase 3 没做: 跑 render-case.py)")
  125. return gaps
  126. def _load_env() -> None:
  127. """加载仓库根 .env 到环境变量。
  128. 各 provider 的 create_*_llm_call 直接 os.getenv 读 key / base_url
  129. (OPEN_ROUTER_API_KEY / QWEN_API_KEY / QWEN_BASE_URL 等), 但本脚本绕过
  130. agent.client (那里才 load_dotenv), 故在此显式加载, 否则 .env 里的配置读不到。
  131. override=False: 已在 shell 里 export 的值优先, 不被 .env 覆盖。
  132. """
  133. try:
  134. from dotenv import load_dotenv
  135. except ImportError:
  136. return
  137. env_file = REPO_ROOT / ".env"
  138. if env_file.exists():
  139. load_dotenv(env_file, override=False)
  140. def _load_sibling_module(name: str, path: Path):
  141. """按文件路径 import 同目录脚本 (run_procedure_dsl.py 不是包, 用 spec 加载)。"""
  142. spec = importlib.util.spec_from_file_location(name, path)
  143. mod = importlib.util.module_from_spec(spec)
  144. spec.loader.exec_module(mod)
  145. return mod
  146. # 复用 run_procedure_dsl.py 的纯函数 (它 main() 有 __main__ 守卫, import 无副作用)。
  147. _rpd = _load_sibling_module("run_procedure_dsl", DSL_ROOT / "run_procedure_dsl.py")
  148. _build_initial_blocks = _rpd._build_initial_blocks
  149. _images_from_source = _rpd._images_from_source
  150. _url_to_cached_path = _rpd._url_to_cached_path
  151. _derive_case_id = _rpd._derive_case_id
  152. _resolve_out_dir = _rpd._resolve_out_dir
  153. _MEDIA_TYPE = _rpd._MEDIA_TYPE
  154. # 极简引导: 只告诉主 Agent「你是 Cyber Agent + 工具名 + 去读 README 的运行时约定节」。
  155. # 其余所有运行时规则都搬进了 spec/README.md 的「🛠 运行时约定」节(agent 本来就先读 README)。
  156. _CYBER_RUNTIME_NOTE = """
  157. ⚠️ 你的执行引擎是 **Cyber Agent**(不是 Claude Code):可用工具是 `read_file` / `write_file` / `edit_file` / `bash_command` / `glob_files` / `grep_content` / `read_images`(**不是** Read/Write/Edit/Bash/Glob/Grep)。
  158. **第一步就 read_file `spec/README.md`**(在你 cwd `procedure-dsl/` 下),然后**严格照它的「🛠 运行时约定」节操作** —— 尤其『仅 Cyber Agent 引擎』那部分(`bash_command` 是 cmd.exe、`goal` 用纯数字 focus、spec 相对链接补 `spec/` 前缀、read_file 会截断长文本)。「怎么建 workflow.json / 怎么用 @quote 填 value / patch 路径怎么写」全以 README 那节 + 各阶段文件为准, 不要自行发挥。
  159. """
  160. def _downscale_image(raw: bytes, max_dim: int, quality: int) -> tuple:
  161. """把图片下采样 + 重压缩成 JPEG, 返回 (bytes, mime)。
  162. 为什么必须做: 实测 case 的 14 张原图 base64 合计 ~12MB, 直接发会把
  163. OpenRouter→Claude 的上游流打断 (api_error: internal stream ended unexpectedly);
  164. 减到 ~3MB 内就稳。同时大幅省 input token (PNG 截图 base64 极占 token)。
  165. 策略: 最长边 > max_dim 才缩放 (保持比例); 一律转 JPEG (PNG 截图转 JPEG 体积降一个量级);
  166. 有透明通道的拍平到白底 (截图场景安全)。max_dim<=0 表示关闭, 原样返回。
  167. PIL 不可用或处理失败 → 原样返回 (降级不阻塞)。
  168. """
  169. if max_dim <= 0:
  170. return raw, "image/jpeg"
  171. try:
  172. import io
  173. from PIL import Image
  174. im = Image.open(io.BytesIO(raw))
  175. if im.mode in ("RGBA", "LA", "P"):
  176. bg = Image.new("RGB", im.size, (255, 255, 255))
  177. im = im.convert("RGBA")
  178. bg.paste(im, mask=im.split()[-1])
  179. im = bg
  180. else:
  181. im = im.convert("RGB")
  182. w, h = im.size
  183. if max(w, h) > max_dim:
  184. scale = max_dim / max(w, h)
  185. im = im.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
  186. out = io.BytesIO()
  187. im.save(out, format="JPEG", quality=quality, optimize=True)
  188. return out.getvalue(), "image/jpeg"
  189. except Exception as e:
  190. print(f"[image] downscale 失败, 用原图: {type(e).__name__}: {e}", flush=True)
  191. return raw, "image/png"
  192. def _to_openai_content(text: str, images: List[str],
  193. max_dim: int = 1280, quality: int = 85) -> List[Dict[str, Any]]:
  194. """把 (text, 图URL列表) 拼成 OpenAI 格式的 content blocks (OpenRouter / 各家通吃)。
  195. - 文本块: {"type": "text", "text": ...}
  196. - 图片块: {"type": "image_url", "image_url": {"url": "data:<mime>;base64,<...>"}}
  197. URL 先经 run_procedure_dsl._url_to_cached_path 客户端下载缓存 (绕图床 robots.txt)。
  198. 每张图经 _downscale_image 下采样+转 JPEG (max_dim<=0 关闭), 防大 payload 打断上游流。
  199. 单张图失败不阻塞整批。
  200. """
  201. blocks: List[Dict[str, Any]] = [{"type": "text", "text": text}]
  202. n_ok, n_fail = 0, 0
  203. bytes_before, bytes_after = 0, 0
  204. for ref in images:
  205. try:
  206. if ref.startswith(("http://", "https://")):
  207. local = _url_to_cached_path(ref)
  208. else:
  209. local = Path(ref).expanduser().resolve()
  210. if not local.exists():
  211. raise FileNotFoundError(ref)
  212. raw = local.read_bytes()
  213. small, mime = _downscale_image(raw, max_dim, quality)
  214. bytes_before += len(raw)
  215. bytes_after += len(small)
  216. data = base64.standard_b64encode(small).decode()
  217. blocks.append({
  218. "type": "image_url",
  219. "image_url": {"url": f"data:{mime};base64,{data}"},
  220. })
  221. n_ok += 1
  222. except Exception as e:
  223. n_fail += 1
  224. print(f"[image] skip {ref[:80]}... ({type(e).__name__}: {e})", flush=True)
  225. if n_ok and max_dim > 0:
  226. print(f"[image] 下采样: {bytes_before//1024}KB → {bytes_after//1024}KB "
  227. f"(max_dim={max_dim}, q={quality})", flush=True)
  228. if images:
  229. print(f"[image] {n_ok}/{len(images)} 成功 base64 化, {n_fail} 失败已跳过", flush=True)
  230. return blocks
  231. # ──── 执行前预 OCR: 每张配图 → 文本 (供 quote-source --ocr 搜) ──────────────────
  232. # 截图教程的 prompt / JSON / 参数常只在图里, body_text 抽不到。执行前 OCR 成文本,
  233. # 让 LLM 也能从图片内容里 quote 出真实 value/directive。按图字节 hash 缓存, 不重复花钱。
  234. def _ocr_one(raw: bytes, model: str, api_key: str,
  235. max_dim: int = 2000, quality: int = 90) -> str:
  236. """单张图 → OCR 文本 (OpenRouter 视觉调用)。失败抛异常由上层兜。"""
  237. small, mime = _downscale_image(raw, max_dim, quality)
  238. data = base64.standard_b64encode(small).decode()
  239. instr = ("请把这张图片里的所有文字逐字提取出来, 按从上到下、从左到右的阅读顺序输出。"
  240. "只输出图中文字本身, 不要翻译、不要解释、不要添加任何说明。"
  241. "图中若有代码/JSON/提示词, 请保留其原始换行与格式。若图中无文字, 输出空。")
  242. payload = {
  243. "model": model,
  244. "messages": [{"role": "user", "content": [
  245. {"type": "text", "text": instr},
  246. {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{data}"}},
  247. ]}],
  248. }
  249. r = httpx.post("https://openrouter.ai/api/v1/chat/completions",
  250. headers={"Authorization": f"Bearer {api_key}"}, json=payload, timeout=120)
  251. r.raise_for_status()
  252. j = r.json()
  253. return (j.get("choices") or [{}])[0].get("message", {}).get("content", "") or ""
  254. def _ocr_images(refs: List[str], model: str, api_key: str, cache_dir: Path) -> str:
  255. """对每张图 OCR, 合并成带分段标记的文本。按图字节 hash 缓存, 单张失败跳过不阻塞。"""
  256. import hashlib
  257. cache_dir.mkdir(exist_ok=True)
  258. out, n_ok = [], 0
  259. for n, ref in enumerate(refs, 1):
  260. try:
  261. if ref.startswith(("http://", "https://")):
  262. local = _url_to_cached_path(ref)
  263. else:
  264. local = Path(ref).expanduser().resolve()
  265. raw = local.read_bytes()
  266. h = hashlib.sha256(raw).hexdigest()[:24]
  267. cf = cache_dir / f"{h}.txt"
  268. if cf.exists():
  269. txt, tag = cf.read_text(encoding="utf-8"), " (cache)"
  270. else:
  271. txt, tag = _ocr_one(raw, model, api_key), ""
  272. cf.write_text(txt, encoding="utf-8")
  273. out.append(f"\n===== [图 {n}] 来源: {ref[:90]} =====\n{txt.strip()}\n")
  274. n_ok += 1
  275. print(f"[ocr] 图 {n}/{len(refs)}: {len(txt.strip())} 字{tag}", flush=True)
  276. except Exception as e:
  277. print(f"[ocr] 图 {n}/{len(refs)} 失败跳过: {type(e).__name__}: {e}", flush=True)
  278. print(f"[ocr] {n_ok}/{len(refs)} 张成功", flush=True)
  279. return "".join(out)
  280. def _trace_append(trace_path: Path, chunk: str) -> None:
  281. with trace_path.open("a", encoding="utf-8") as f:
  282. f.write(chunk)
  283. def _content_to_text(content: Any) -> str:
  284. """把 Message.content 归一成纯文本。
  285. 不同 provider 的 content 形态不一:
  286. - str: 直接用 (OpenRouter / 多数情况)。
  287. - list[block]: OpenAI/Qwen 多模态格式 [{"type":"text","text":...}, ...],
  288. 抽出各块的 text 字段拼起来。
  289. - dict: 单个 block, 取其 text / content 字段, 取不到就 str() 兜底。
  290. 切片 (content[:2000]) 前必须先过这里, 否则对 dict/list 切片会抛 KeyError/TypeError。
  291. """
  292. if isinstance(content, str):
  293. return content
  294. if isinstance(content, list):
  295. parts = []
  296. for b in content:
  297. if isinstance(b, str):
  298. parts.append(b)
  299. elif isinstance(b, dict):
  300. parts.append(b.get("text") or b.get("content") or "")
  301. return "".join(parts)
  302. if isinstance(content, dict):
  303. # Qwen/DeepSeek assistant: text 可能为空, 真正的话在 reasoning_content。
  304. # tool 结果消息: 内容在 "result" 键 (之前漏读它, 导致 [tool result] 控制台全空白)。
  305. # 都取不到就返回 "" (不要 str(content) 把整个 dict 当文本 dump 出来)。
  306. return (content.get("text") or content.get("reasoning_content")
  307. or content.get("result") or content.get("content") or "")
  308. return str(content)
  309. # 两段式 (--phase1-model): Pass 1 只做 Phase 1, 然后换模型 resume 做 Phase 2+。
  310. _PHASE1_STOP_NOTE = """
  311. ## ⏸️ 本段任务: 先完成 Phase 1, 然后**暂停等待下一步指示**
  312. 这是一个**分阶段协作**的任务, 你负责的是**第一阶段**。本段请专注做完 Phase 1:
  313. - Phase 1.1 心智模型 → 写 understanding.md
  314. - Phase 1.2 workflow.json 骨架 (procedures/steps/IO 结构 + name/purpose/declarations)
  315. - Phase 1.3 anchor 闭合 (IO 引用)
  316. 完成 Phase 1.3 后, 请**暂停**: 用一句话报告产出, **本轮不再发任何工具调用**, 等待后续指示来推进 Phase 2/3。
  317. (注意: 这**不是禁止** Phase 2, 只是分工上**这一段先到 Phase 1 为止**; 后续会有新指示让你或另一协作者继续。)
  318. 你的 Phase 1 产出质量直接决定后续阶段, 所以 understanding.md 和 workflow.json 骨架务必扎实、完整。
  319. """
  320. # ── 实验模式 note (--exp) ──────────────────────────────────────────────────
  321. # 实验只比 Phase 1 骨架质量, 两方案都"产出 workflow.json 骨架+anchor 后停", 不跑 Phase 2/3。
  322. # 方案 1 (direct): 强模型不写 understanding.md, 边想边直接出 workflow.json。
  323. _EXP_DIRECT_NOTE = """
  324. ## 🧪 实验模式 (direct): 不写 understanding.md, 直接产 workflow.json
  325. 本次**跳过 Phase 1.1 的 understanding.md 文件** (spec 里提到的这一步本次作废, 不要 Write 它)。
  326. 把"有几个独立工序、每个工序的步骤/IO/控制流"的分析**直接写在你的文字回复里**(简明扼要), 然后:
  327. - 直接 Write `workflow.json` 骨架 (Phase 1.2: procedures/steps/IO 结构 + name/purpose/declarations);
  328. - 用 wf-patch.py 加 anchor (Phase 1.3: IO 闭合)。
  329. 完成 anchor 闭合后**立即停止**, 一句话总结即可, **不要进入 Phase 2** (不填 effect/action/type/substance/form, 不分发子 Agent)。
  330. """
  331. # 方案 2 第一步 (split-A): 强模型只产 understanding.md, 不碰 workflow.json。精简读单。
  332. _EXP_UNDERSTANDING_ONLY_NOTE = """
  333. ## 🧪 实验模式 (split · 第一步): 只产 understanding.md
  334. 本次**只做 Phase 1.1**: 通读原文(含图)建立心智模型, 写进 understanding.md, 然后**立即停止**
  335. (**不要 Write workflow.json**, 不进 Phase 1.2+, **本轮不再发任何工具调用**)。
  336. ### ⚡ 精简读单 (覆盖上面起手指令里的完整清单 — 以本节为准)
  337. 本步**只读**这几样, 其余一律不读 (读了纯烧 context, 它们是给下游做 workflow.json 的步骤用的):
  338. - `spec/README.md` (已在起手读过, 别重读)
  339. - `spec/syntax.md` (DSL 概念: procedure/step/IO/effect/action — 让心智模型用对术语)
  340. - `spec/extraction/phase1-skeleton.md` (**多工序判断标准** — 怎么判定有几个独立工序)
  341. - 原文 case json (body_text + 元数据) + 本消息所附的图
  342. **明确不要读** (本步用不上):
  343. - `spec/tools.md` (脚本接口 — 本步不调任何脚本)
  344. - `spec/extraction/fields.md` (23 字段填法)
  345. - `spec/extraction/control-flow.md` (block/nested 的 JSON 建模)
  346. - `spec/format/md-structure.md` (.md 产物结构)
  347. ### understanding.md 要写到"能让另一个模型照着填出 workflow.json"的程度
  348. - 有几个独立工序 (按 phase1-skeleton 判断标准), 每个: 工序名 + 终态产物 + 大致步骤数 + 工艺类型;
  349. - 每个工序的步骤序列, 每步的输入/输出 (是什么数据、从哪来、到哪去);
  350. - **控制流用大白话讲清** (哪步是循环/并行/分支、循环什么、并行几路) —— 你不必读 control-flow.md,
  351. 文字描述即可, 下游模型据此建 block/nested。
  352. 后续由另一个模型读你的 understanding.md + JSON schema 生成 workflow.json。
  353. """
  354. # 方案 2 第二步 (split-B): 全新一段、不给图, 弱模型只凭 understanding.md + schema 产 workflow.json。
  355. _EXP_WORKFLOW_FROM_UNDERSTANDING_NOTE = """
  356. ## 🧪 实验模式 (split · 第二步): 据 understanding.md 产 workflow.json (本次不附原图)
  357. Phase 1.1 的心智模型已由**另一个(更强的)模型**写好 —— 就是上面"输出目录"里的 **understanding.md**。
  358. 本次你的任务是 **Phase 1.2 + 1.3**, 且**只依据 understanding.md + schema**(本消息不附原图, 你看不到截图):
  359. 1. read_file 输出目录里的 `understanding.md`, 以及 spec 的 `format/case-data.schema.json`;
  360. 2. 按 understanding.md 的工序划分, Write `workflow.json` 骨架 (procedures/steps/IO 结构 + name/purpose/declarations);
  361. 3. 用 bash_command 跑 wf-patch.py 加 anchor (IO 闭合, 单条命令不要拼 `;`)。
  362. 完成 anchor 后**立即停止**, **不要进入 Phase 2**, 也**不要重写 understanding.md**。
  363. """
  364. async def run(args: argparse.Namespace) -> int:
  365. from agent.core.runner import AgentRunner, RunConfig, KnowledgeConfig
  366. from agent.core.presets import load_presets_from_json
  367. from agent.trace import FileSystemTraceStore, Trace, Message
  368. from agent.llm import create_openrouter_llm_call, create_qwen_llm_call
  369. # provider 选择: 决定 llm_call 走哪家端点。
  370. # openrouter → OPEN_ROUTER_API_KEY, 一个 URL 通打各家 (model 形如 qwen/qwen-max)。
  371. # qwen → QWEN_API_KEY + QWEN_BASE_URL (.env), 阿里 dashscope 原生
  372. # (model 形如 qwen-plus / qwen-max, 无 "qwen/" 前缀)。
  373. if args.provider == "qwen":
  374. make_llm_call = lambda: create_qwen_llm_call(model=args.model)
  375. else:
  376. make_llm_call = lambda: create_openrouter_llm_call(model=args.model)
  377. workdir = DSL_ROOT
  378. source_path = Path(args.source).expanduser().resolve()
  379. if not source_path.exists():
  380. print(f"❌ source not found: {source_path}", file=sys.stderr)
  381. return 1
  382. out_dir = _resolve_out_dir(args.out_dir, workdir)
  383. out_dir.mkdir(parents=True, exist_ok=True)
  384. (out_dir / "_scratch").mkdir(exist_ok=True)
  385. trace_id_file = out_dir / ".trace_id"
  386. trace_path = out_dir / "_trace_cyber.md"
  387. # 注册 main preset (单 Agent; phase-2a/2b 子 Agent 已废弃, 见 spec/tools.md §7)。
  388. presets_json = DSL_ROOT / "presets_cyber.json"
  389. if presets_json.exists():
  390. load_presets_from_json(str(presets_json))
  391. else:
  392. print(f"⚠️ 缺少 {presets_json}, 用 runner 默认 main preset", file=sys.stderr)
  393. # source 路径给 Agent (workdir 相对优先)。
  394. try:
  395. source_for_agent = source_path.relative_to(workdir).as_posix()
  396. except ValueError:
  397. source_for_agent = str(source_path)
  398. # resume: 读已存 trace_id, 只发增量 "接着做" 消息。
  399. resume_tid = None
  400. if args.resume:
  401. if not trace_id_file.exists():
  402. print(f"❌ --resume 但无 {trace_id_file}; 先正常跑一次", file=sys.stderr)
  403. return 1
  404. resume_tid = trace_id_file.read_text(encoding="utf-8").strip() or None
  405. images = _images_from_source(source_path) + (args.extra_image or [])
  406. # 执行前预 OCR: 把每张配图的文字提取成文本, 落 _scratch/ocr.txt, 供 quote-source --ocr 搜。
  407. # 只在 fresh run 做 (resume 时上次的 ocr.txt 还在); 按图字节 hash 缓存, 重跑不重复花钱。
  408. ocr_path = out_dir / "_scratch" / "ocr.txt"
  409. if not resume_tid and not getattr(args, "no_ocr", False) and images:
  410. api_key = os.getenv("OPEN_ROUTER_API_KEY")
  411. if not api_key:
  412. print("[ocr] 跳过: 未设 OPEN_ROUTER_API_KEY", flush=True)
  413. else:
  414. print(f"[ocr] 对 {len(images)} 张配图预 OCR (model={args.ocr_model}) ...", flush=True)
  415. ocr_text = _ocr_images(images, args.ocr_model, api_key, DSL_ROOT / ".ocr_cache")
  416. if ocr_text.strip():
  417. ocr_path.write_text(ocr_text, encoding="utf-8")
  418. print(f"[ocr] -> {ocr_path} (共 {len(ocr_text)} 字)", flush=True)
  419. spec_name = "spec" if not getattr(args, "spec_version", None) else f"spec-{args.spec_version}"
  420. if resume_tid and getattr(args, "_phase2_handoff", False):
  421. # 两段式 Pass 2: Phase 1 已由另一模型做完, 从 Phase 2 开始。
  422. # ⚠️ 必须强硬作废历史里 Pass1 的"只做 Phase1 就停"指令, 否则弱模型会跟着旧指令
  423. # 重做 Phase1 再停 (实测 gemini-flash-lite 就这么干了)。
  424. cd = out_dir.as_posix()
  425. msgs = [{"role": "user", "content": (
  426. f"【阶段交接 — 之前的指令已变更, 请严格按本条执行】\n\n"
  427. f"Phase 1 (understanding.md + workflow.json 骨架 + anchor 闭合) **已经全部完成并落盘**, 是上一个模型做的。\n\n"
  428. f"⚠️ 历史里那条『本次只做 Phase 1、做完即停、不要进 Phase 2』的指令**现已作废**。"
  429. f"你现在的唯一任务是完成 **Phase 2 和 Phase 3**。\n\n"
  430. f"❌ **绝对不要**重写 / 重新生成 understanding.md 或 workflow.json 的骨架 —— 它们已经做好了, 重做即错误。\n"
  431. f"✅ 现在立刻执行 (用 bash_command, 单条命令不要拼 `;`):\n"
  432. f" 1. read_file `{cd}/workflow.json` 看当前骨架 (不要凭记忆, 也不要重写它)。\n"
  433. f" 2. 读 spec/extraction/phase2-normalize.md, 由你**自己一趟做完 Phase 2**: 作用/动作/类型 对词表、"
  434. f"实质/形式 直接提炼元素点 (不查词表)、每步填 intent。**不要**切任务 / 分发子 Agent。\n"
  435. f" 3. 用 wf-patch.py (bash_command, --set 或 --patch) 回填 effect/action/type/substance/form/intent 到 workflow.json。\n"
  436. f" 4. Phase 3: 跑 lint-case.py 校验, 再 render-case.py 出 HTML (.html 是唯一产物, .md 已取消)。"
  437. )}]
  438. elif resume_tid:
  439. msgs = [{"role": "user", "content": (
  440. f"上次中断了, 接续做 case-{args.case_id} 的提取流程。\n"
  441. f"先用 bash_command `ls` 看 {out_dir.as_posix()}/ 当前已落盘哪些产物, "
  442. f"再 read_file 这些**当前磁盘版本** (understanding.md / workflow.json) 接着跑, "
  443. f"不要凭记忆。Phase 2 由你自己一趟做完 (wf-patch.py 落盘, 不分发子 Agent)。"
  444. )}]
  445. else:
  446. # 复用原脚本的起手 prompt 全文 (取 text 块), 再补 Cyber 运行时说明。
  447. anth_blocks = _build_initial_blocks(
  448. source_for_agent, args.case_id, args.out_dir, images, workdir, spec_name
  449. )
  450. base_text = anth_blocks[0]["text"] + _CYBER_RUNTIME_NOTE
  451. if ocr_path.exists():
  452. base_text += (
  453. f"\n\n## 🖼️ 配图已 OCR 成文本\n"
  454. f"原文配图的文字已 OCR 提取到 `{ocr_path.as_posix()}`。"
  455. f"填 value/directive 需要图里的文字(prompt/JSON/参数常只在图中)时, "
  456. f"用 `python spec/tools/quote-source.py --source {source_for_agent} --query \"<短语>\" --ocr {ocr_path.as_posix()}` "
  457. f"一并搜原文+图片 (quote-source 读全文件, 不受 read_file 截断影响; 别用 read_file 通读大 ocr.txt)。"
  458. )
  459. # 内联完整正文: read_file 会把 body_text 这种超长单行砍在 2000 字 (弱模型常不续 char_offset
  460. # → 正文后半段静默丢失)。把完整 body_text 直接附进 prompt, agent 理解正文以这份为准。
  461. try:
  462. _sd = json.loads(source_path.read_text(encoding="utf-8"))
  463. _body = _sd.get("body_text") if isinstance(_sd, dict) else None
  464. except Exception:
  465. _body = None
  466. if _body:
  467. base_text += (
  468. f"\n\n## 📄 原文正文 (完整版, 已内联 — 别再 read_file 原文取正文)\n"
  469. f"⚠️ read_file 读 `{source_for_agent}` 会把 body_text 这一长行砍在 2000 字 → 丢后半段。"
  470. f"理解正文、提取 value/directive **以下面这份完整正文为准**; read_file 原文文件只为取 "
  471. f"title/link/publish_timestamp 等短字段。\n\n```\n{_body}\n```"
  472. )
  473. # 给「计划」+「IO 校验」工具注入原文 + 配图 OCR + 输出目录 + source 元信息。
  474. _sd2 = _sd if isinstance(_sd, dict) else {}
  475. _ocr_text = ocr_path.read_text(encoding="utf-8") if ocr_path.exists() else ""
  476. _lint_ocr = f" --ocr {ocr_path.as_posix()}" if ocr_path.exists() else "" # verify-io / lint 共用
  477. plan_tool.set_plan_context(
  478. body_text=_body or "",
  479. ocr=_ocr_text,
  480. out_dir=out_dir,
  481. case_id=args.case_id,
  482. source={
  483. "platform": "", # LLM/后续可补
  484. "author": _sd2.get("channel_account_name", ""),
  485. "url": _sd2.get("link", ""),
  486. "title": _sd2.get("title", ""),
  487. "date": str(_sd2.get("publish_timestamp", "") or ""),
  488. "excerpt": (_body or "")[:120],
  489. },
  490. )
  491. base_text += (
  492. f"\n\n## 🧭 第一步(必做): 调用 plan_procedures 工具做计划\n"
  493. f"在动手建 workflow.json 前, **先调用一次 `plan_procedures` 工具**, 交上你的工序计划: "
  494. f"把这篇拆成几个工序、每个工序的步骤逐条展开(工具·输入·动作·输出四要素)、并声明每个工序"
  495. f"覆盖原文哪些 `0N` 章节(source_sections)。工具会校验: 有章节没被任何工序认领 → 报错让你补; "
  496. f"通过后**自动据计划生成 workflow.json 骨架**(工序/步骤/顺序锁定)。之后你只在骨架上用 "
  497. f"wf-patch 填 value/directive/anchor, **不要再 write_file 重写 workflow.json、也不增删工序/步骤**。\n"
  498. f"填完 value/directive/anchor(这是 **Phase 2.0 填内容**)后, **必须跑一次 IO 校验脚本**(照抄):\n"
  499. f"```bash\npython spec/tools/verify-io.py --workflow {out_dir.as_posix()}/workflow.json "
  500. f"--source {source_for_agent}{_lint_ocr}\n```\n"
  501. f"它校验每个文本 IO 的 value 是否逐字、**每个生成步有没有 type=提示词 的输入**(提示词是数据不是 directive)、"
  502. f"提示词 value 是否完整不截断、declarations 是否补全; "
  503. f"报 ✗ 就修(此时可重读原文, 提示词用 @quote 提**全**别缩写/截断)再跑, 校验通过才进 **Phase 2.1 归类标注**(effect/action/type/substance/form/intent)。"
  504. )
  505. # Phase 3 lint 必须带 --source(+--ocr)才会跑「章节覆盖」+「value 逐字」两条结构/值强制;
  506. # 这里钉死精确路径, 免得 agent 用错文件名导致校验静默跳过。(_lint_ocr 已在上面定义)
  507. base_text += (
  508. f"\n\n## ✅ Phase 3 lint 命令(照抄, 别省 --source)\n"
  509. f"```bash\npython spec/tools/lint-case.py --workflow {out_dir.as_posix()}/workflow.json "
  510. f"--case-id {args.case_id} --source {source_for_agent}{_lint_ocr}\n```\n"
  511. f"带 `--source` 才会查**章节覆盖**(原文每个 `0N` 章节都要落进某工序/步骤, 别整段漏抽)"
  512. f"和 **value 逐字**(文本类 value 要是原文一整段连续文本, 别抄开头后缩写)。"
  513. f"报「章节疑似漏抽」回 Phase 1 补工序; 报「value 疑似缩写」回 Phase 2.0 用 `@quote` 重填。"
  514. )
  515. imgs_for_prompt = images
  516. if getattr(args, "_exp_direct", False):
  517. base_text += _EXP_DIRECT_NOTE # 方案1: 不写 understanding, 直接 workflow.json
  518. elif getattr(args, "_exp_understanding_only", False):
  519. base_text += _EXP_UNDERSTANDING_ONLY_NOTE # 方案2 第一步: 只产 understanding
  520. elif getattr(args, "_exp_workflow_from_understanding", False):
  521. base_text += _EXP_WORKFLOW_FROM_UNDERSTANDING_NOTE # 方案2 第二步: 据 understanding 产 workflow
  522. imgs_for_prompt = [] # 不给图, 纯凭 understanding.md + schema
  523. elif getattr(args, "phase1_only", False):
  524. base_text += _PHASE1_STOP_NOTE # 两段式 Pass 1: 做完整 Phase 1 即停
  525. msgs = [{"role": "user", "content": _to_openai_content(
  526. base_text, imgs_for_prompt, max_dim=args.max_image_dim, quality=args.image_quality)}]
  527. cfg = RunConfig(
  528. model=args.model,
  529. temperature=0.3,
  530. max_iterations=args.max_turns,
  531. agent_type="main",
  532. name=f"procedure-dsl case-{args.case_id} (cyber)",
  533. tool_groups=["core", "system"], # core=read/write/edit/glob/grep (agent 工具下面 exclude 掉);
  534. # system=bash_command
  535. # ⚠️ 没 system 组 → 主 Agent 无 bash, 跑不了 spec/tools/*.py
  536. # (wf-patch / lint-case / render-case 全靠 bash)
  537. parallel_tool_execution=True, # 允许同轮并行工具调用 (如多个 read_file); 子 Agent 分发已废弃
  538. context_injection_interval=0, # 关掉周期性自动注入 get_current_context: procedure-dsl 单 Agent
  539. # 不用 goal/协作者/IM, 那些注入只是给弱模型添乱 + 烧 token
  540. enable_prompt_caching=False, # 非 Claude 模型无效, 关掉省得干扰
  541. # 关掉 goal 压缩: 它会在 goal 完成后把详细消息压成 [[SUMMARY]], 而弱模型 (如
  542. # gemini-flash-lite) 一丢细节就倾向"推倒重做 Phase 1", 覆盖掉已完成的 Phase 2
  543. # 归一化数据。单 case 运行上下文有限, 保留全量更安全。
  544. goal_compression="none",
  545. # 关掉知识沉淀: 否则任务结束会被自动注入"复盘→knowledge_save_pending"prompt
  546. # (上次 Claude 在 seq6 被它带跑偏、qwen 浪费 turn51-52)。procedure-dsl 不需要它。
  547. knowledge=KnowledgeConfig(
  548. enable_extraction=False, # 压缩时不反思
  549. enable_completion_extraction=False, # 结束后不复盘 (核心: 去掉那段收尾 prompt)
  550. enable_injection=False, # focus goal 时不注入知识
  551. ),
  552. exclude_tools=["knowledge_save_pending", "agent", "evaluate"], # 去知识沉淀 + 子 Agent 分发工具 (agent/evaluate): 单 Agent 全程
  553. trace_id=resume_tid,
  554. )
  555. print(f"[setup] engine = Cyber AgentRunner")
  556. print(f"[setup] provider = {args.provider}")
  557. print(f"[setup] model = {args.model}")
  558. print(f"[setup] source = {source_path}")
  559. print(f"[setup] case_id = {args.case_id}")
  560. print(f"[setup] out_dir = {out_dir}")
  561. print(f"[setup] images = {len(images)}")
  562. print(f"[setup] max_iter = {args.max_turns}")
  563. print(f"[setup] resume = {resume_tid[:8] + '...' if resume_tid else 'no'}")
  564. print(flush=True)
  565. now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  566. _trace_append(trace_path, f"\n\n---\n\n## ▶ {'Resume' if resume_tid else 'Fresh'} @ {now}\n"
  567. f"- model: `{args.model}` · case: `{args.case_id}` · images: `{len(images)}`\n")
  568. # ⚠️ trace store 必须放**短路径**(仓库根 .trace), 不能放 out_dir/.trace。
  569. # 原因 (Windows MAX_PATH=260): 子 Agent 的 trace_id 是 <父UUID>@delegate-<时间戳>-NNN,
  570. # 消息文件名还把整个 id 重复一次。若 base 是深层的 outputs/<case>/.trace,
  571. # 子 agent 消息文件路径会到 ~285 字符 > 260, 落盘报 [Errno 2] 子 Agent 直接失败。
  572. # 放仓库根 .trace 后同样路径 ~204 < 260。各 case 的 trace 按 trace_id 区分, 不冲突。
  573. trace_store_base = REPO_ROOT / ".trace"
  574. runner = AgentRunner(
  575. llm_call=make_llm_call(),
  576. trace_store=FileSystemTraceStore(base_path=str(trace_store_base)),
  577. debug=True, # subagent.py 据此打印子 Agent (phase-2a/2b) 的实时执行过程,
  578. # 否则子 Agent 全程静默, 只有最后 delegate 汇总可见。
  579. )
  580. turn = 0
  581. t0 = time.time()
  582. status = "unknown"
  583. # token / 成本累计 (主 trace; 子 Agent 的 token 在各自子 trace, 不计入此处)。
  584. usage = {"in": 0, "out": 0, "cache_w": 0, "cache_r": 0, "cost": 0.0}
  585. # 完成度兜底: 一轮跑完若 workflow 没填全/没出 HTML(弱模型常吐空消息提前自停),
  586. # 带「还差哪些」的具体清单**续同一条 trace** 再跑, 直到完成或达上限。
  587. # 实验/两段式模式(phase1_only/_exp_*)是故意中途停的, 不兜底。
  588. _exp_mode = (getattr(args, "phase1_only", False) or getattr(args, "_exp_direct", False)
  589. or getattr(args, "_exp_understanding_only", False)
  590. or getattr(args, "_exp_workflow_from_understanding", False))
  591. max_auto = 0 if _exp_mode else getattr(args, "max_auto_continue", 2)
  592. run_msgs = msgs
  593. cur_trace = resume_tid
  594. attempt = 0
  595. read_phase: set = set() # agent 读过哪些阶段规格文件 (监听 read_file 累计)
  596. try:
  597. while True:
  598. async for item in runner.run(messages=run_msgs, config=cfg):
  599. if isinstance(item, Trace):
  600. status = item.status
  601. if item.trace_id:
  602. cur_trace = item.trace_id
  603. trace_id_file.write_text(item.trace_id, encoding="utf-8")
  604. print(f"[trace] {item.trace_id} status={item.status}", flush=True)
  605. elif isinstance(item, Message):
  606. role = getattr(item, "role", "?")
  607. raw_content = getattr(item, "content", "") or ""
  608. tool_calls = getattr(item, "tool_calls", None)
  609. # Qwen 原生: 整条消息塞在 content dict 里, tool_calls 也嵌在其中,
  610. # item.tool_calls 属性反而是空 —— 从 content 兜底捞出来。
  611. if not tool_calls and isinstance(raw_content, dict):
  612. tool_calls = raw_content.get("tool_calls")
  613. content = _content_to_text(raw_content)
  614. # 累计 token/成本 (token 字段挂在 assistant 消息上; tool 消息为 None → or 0)
  615. usage["in"] += getattr(item, "prompt_tokens", 0) or 0
  616. usage["out"] += getattr(item, "completion_tokens", 0) or 0
  617. usage["cache_w"] += getattr(item, "cache_creation_tokens", 0) or 0
  618. usage["cache_r"] += getattr(item, "cache_read_tokens", 0) or 0
  619. usage["cost"] += getattr(item, "cost", 0.0) or 0.0
  620. if role == "assistant":
  621. turn += 1
  622. if content:
  623. print(f"\n[turn {turn} · text]\n{content}\n", flush=True)
  624. _trace_append(trace_path, f"\n### Turn {turn}\n> {content[:2000]}\n")
  625. for tc in (tool_calls or []):
  626. fn = (tc.get("function") or {}) if isinstance(tc, dict) else {}
  627. nm = fn.get("name", tc.get("name", "?") if isinstance(tc, dict) else "?")
  628. args_full = str(fn.get("arguments", ""))
  629. ar = args_full[:200]
  630. print(f"[turn {turn} · tool] {nm}({ar})", flush=True)
  631. _trace_append(trace_path, f"- `{nm}` — `{ar}`\n")
  632. # 监听阶段文件读取 (read_file 的 file_path 里命中阶段文件名)
  633. if nm == "read_file":
  634. for _key, _ph in _PHASE_FILES.items():
  635. if _key in args_full:
  636. read_phase.add(_ph)
  637. elif role == "tool":
  638. preview = str(content)[:300]
  639. print(f" ↳ [tool result] {preview}", flush=True)
  640. # 一轮跑完 → 查完成度 (阶段文件没读的排最前: 先读规则再修输出)
  641. gaps = _phase_read_gaps(out_dir, read_phase) + _completion_gaps(out_dir)
  642. if not gaps:
  643. break
  644. if attempt >= max_auto:
  645. if max_auto > 0:
  646. print(f"\n⚠️ 达自动续跑上限({max_auto})仍未完成: {'; '.join(gaps)}", flush=True)
  647. _trace_append(trace_path, f"\n### ⚠ 达续跑上限仍未完成: {'; '.join(gaps)}\n")
  648. break
  649. attempt += 1
  650. nudge = (
  651. "⚠️ 任务还没做完, 别停。当前还差(**按顺序处理**):\n"
  652. + "\n".join(f" - {g}" for g in gaps)
  653. + "\n**先 read_file 上面点名没读过的阶段规格文件**(里面写了格式/词表/检查规则), "
  654. "再据规则修后面的问题, **别重做已完成的部分**。提示: 缺 effect/action 的步骤用 "
  655. "wf-patch.py --set 补(action 要对到 action.json 的合法叶子, 如 `元素生成`/`提取/化学提取`, "
  656. "别拼 `图像生成/文生图` 这种不存在的; wf-patch 部分应用, 对的会留下、只补错的); "
  657. "intent 要写成带 {in-type:}/{out-type:}/{act:} 标记的句子; 没出 HTML 就跑 render-case.py。"
  658. )
  659. print(f"\n[auto-continue {attempt}/{max_auto}] 续跑补完: {'; '.join(gaps)}\n", flush=True)
  660. _trace_append(trace_path, f"\n### ↻ auto-continue {attempt}: {'; '.join(gaps)}\n")
  661. cfg.trace_id = cur_trace # 续同一条 trace (不重开)
  662. run_msgs = [{"role": "user", "content": nudge}]
  663. except KeyboardInterrupt:
  664. print(f"\n⚠️ 中断. {out_dir}/ 产物已保留. 续跑: --resume", file=sys.stderr)
  665. return 130
  666. except Exception as e:
  667. logging.exception("cyber run failed")
  668. print(f"❌ {type(e).__name__}: {e}", file=sys.stderr)
  669. return 1
  670. elapsed = time.time() - t0
  671. print(f"\n[done] status={status} turns={turn} wall={elapsed:.1f}s", flush=True)
  672. print(f"[usage] tokens in={usage['in']:,} out={usage['out']:,} "
  673. f"cache_w={usage['cache_w']:,} cache_r={usage['cache_r']:,} · cost=${usage['cost']:.4f} "
  674. f"(model={args.model}; 不含子 Agent)", flush=True)
  675. _trace_append(
  676. trace_path,
  677. f"\n### ◀ done · status={status} · turns={turn} · {elapsed:.1f}s\n"
  678. f"- tokens: in={usage['in']:,} out={usage['out']:,} "
  679. f"cache_w={usage['cache_w']:,} cache_r={usage['cache_r']:,} · cost=${usage['cost']:.4f}\n"
  680. )
  681. args._last_stats = dict(usage) # 供 main() 两段式汇总
  682. return 0 if status in ("completed", "unknown") else 2
  683. def _parse_args() -> argparse.Namespace:
  684. p = argparse.ArgumentParser(
  685. description="跑 procedure-dsl 提取流程 (Cyber AgentRunner + OpenRouter)",
  686. formatter_class=argparse.RawDescriptionHelpFormatter,
  687. epilog=__doc__,
  688. )
  689. p.add_argument("source", help="原始 post 文件 (input/case-N-raw.json)")
  690. p.add_argument("--out-dir", required=True,
  691. help="输出目录名, 落在 outputs/ 下. case_id 自动从 basename 推。")
  692. p.add_argument("--extra-image", action="append", default=[],
  693. help="额外配图 (本地路径 or URL), 可多次。")
  694. p.add_argument("--provider", default="openrouter", choices=["openrouter", "qwen"],
  695. help="LLM 端点: openrouter (默认, OPEN_ROUTER_API_KEY, 一个 URL 通打各家) "
  696. "或 qwen (阿里 dashscope 原生, 读 .env 的 QWEN_API_KEY + QWEN_BASE_URL)。")
  697. p.add_argument("--model", default="openai/gpt-4o",
  698. help="模型名。provider=openrouter 时形如 openai/gpt-4o / qwen/qwen-max / "
  699. "anthropic/claude-sonnet-4.5; provider=qwen 时形如 qwen-plus / qwen-max (无前缀)。")
  700. p.add_argument("--phase1-model", default=None,
  701. help="启用两段式: Phase 1 (心智模型+骨架+anchor) 用这个模型跑完即停, "
  702. "Phase 2+ 换 --model resume 续跑。不传=全程单模型。"
  703. "例: --phase1-model anthropic/claude-sonnet-4.6 --model google/gemini-3.1-flash-lite")
  704. p.add_argument("--phase1-provider", default=None, choices=["openrouter", "qwen"],
  705. help="Phase 1 段的 provider, 默认继承 --provider。")
  706. p.add_argument("--exp", default=None, choices=["direct", "split"],
  707. help="Phase 1 实验模式 (产出 workflow.json 骨架后即停, 不跑 Phase 2/3):\n"
  708. " direct = 强模型(--model)不写 understanding, 边想边直接出 workflow.json;\n"
  709. " split = 强模型(--phase1-model)只产 understanding → 弱模型(--model)据 understanding+schema 产 workflow.json。")
  710. p.add_argument("--spec-version", default=None, metavar="SUFFIX",
  711. help="用 spec-<SUFFIX>/ 目录而非默认 spec/ (实验变体, 不污染原 spec)。")
  712. p.add_argument("--max-turns", type=int, default=300, help="最大迭代轮数 (default: 300)")
  713. p.add_argument("--max-image-dim", type=int, default=1280,
  714. help="图片下采样最长边像素 (default: 1280, 0=关闭)。多张大图 base64 合计过大会"
  715. "打断 OpenRouter→Claude 上游流 (internal stream ended); 下采样+转JPEG 防此并省 token。")
  716. p.add_argument("--image-quality", type=int, default=85,
  717. help="下采样后 JPEG 质量 (default: 85)。截图含文字, 别压太低伤可读性。")
  718. p.add_argument("--resume", action="store_true",
  719. help="从 outputs/<out-dir>/.trace_id 读 trace 续跑")
  720. p.add_argument("--no-ocr", action="store_true",
  721. help="跳过执行前的配图预 OCR (默认开启: 每张图 OCR 成文本落 _scratch/ocr.txt, 供 quote-source --ocr 搜)")
  722. p.add_argument("--ocr-model", default="google/gemini-3.1-flash-lite",
  723. help="预 OCR 用的视觉模型 (default: google/gemini-3.1-flash-lite, 走 OpenRouter)")
  724. p.add_argument("--max-auto-continue", type=int, default=2,
  725. help="完成度兜底: 跑完若 workflow 没填全/没出 HTML(弱模型常吐空消息自停), "
  726. "自动带'还差X'续跑的最大次数 (default: 2, 0=关闭)")
  727. return p.parse_args()
  728. def main() -> None:
  729. for stream in (sys.stdout, sys.stderr):
  730. if hasattr(stream, "reconfigure"):
  731. stream.reconfigure(encoding="utf-8", errors="replace")
  732. logging.basicConfig(level=logging.WARNING)
  733. _load_env() # 把 .env (OPEN_ROUTER_API_KEY / QWEN_API_KEY / QWEN_BASE_URL) 载入环境
  734. args = _parse_args()
  735. args.case_id = _derive_case_id(args.out_dir)
  736. args.phase1_only = False
  737. args._phase2_handoff = False
  738. args._exp_direct = False
  739. args._exp_understanding_only = False
  740. args._exp_workflow_from_understanding = False
  741. args._last_stats = {}
  742. def _g(d, k):
  743. return d.get(k, 0) if d else 0
  744. # ── 实验模式 (--exp): 只产 workflow.json 骨架, 不跑 Phase 2/3 ──
  745. if args.exp == "direct":
  746. # 方案1: 强模型(--model)不写 understanding, 边想边直接出 workflow.json。
  747. print(f"\n{'='*64}\n [exp:direct] {args.provider}/{args.model} · 直接产 workflow.json\n{'='*64}", flush=True)
  748. args._exp_direct = True
  749. sys.exit(asyncio.run(run(args)))
  750. if args.exp == "split":
  751. # 方案2: 强模型(--phase1-model)只产 understanding → 弱模型(--model)据其产 workflow.json。
  752. if not args.phase1_model:
  753. print("❌ --exp split 需要 --phase1-model (强模型, 产 understanding)。", file=sys.stderr)
  754. sys.exit(2)
  755. main_provider, main_model = args.provider, args.model
  756. p1_provider = args.phase1_provider or args.provider
  757. print(f"\n{'='*64}\n [exp:split] A · understanding · {p1_provider}/{args.phase1_model}\n{'='*64}", flush=True)
  758. args.provider, args.model = p1_provider, args.phase1_model
  759. args.resume, args._exp_understanding_only = False, True
  760. rcA = asyncio.run(run(args)); statsA = dict(args._last_stats)
  761. if rcA != 0:
  762. print(f"❌ split-A (understanding) 退出码={rcA}, 不继续。", file=sys.stderr)
  763. sys.exit(rcA)
  764. print(f"\n{'='*64}\n [exp:split] B · workflow.json · {main_provider}/{main_model}\n{'='*64}", flush=True)
  765. args.provider, args.model = main_provider, main_model
  766. args.resume = False # 全新一段: 不继承强模型历史(含图), 只凭 understanding.md + schema
  767. args._exp_understanding_only, args._exp_workflow_from_understanding = False, True
  768. rcB = asyncio.run(run(args)); statsB = dict(args._last_stats)
  769. print(f"\n{'='*64}\n [exp:split] 成本汇总 (case {args.case_id})\n{'='*64}", flush=True)
  770. print(f" A understanding [{args.phase1_model}]: in={_g(statsA,'in'):,} out={_g(statsA,'out'):,} · ${_g(statsA,'cost'):.4f}")
  771. print(f" B workflow.json [{main_model}]: in={_g(statsB,'in'):,} out={_g(statsB,'out'):,} · ${_g(statsB,'cost'):.4f}")
  772. print(f" 合计: ${_g(statsA,'cost') + _g(statsB,'cost'):.4f}", flush=True)
  773. sys.exit(rcB)
  774. # 单模型: 直接跑。
  775. if not args.phase1_model:
  776. sys.exit(asyncio.run(run(args)))
  777. # 两段式: Pass 1 (Phase 1, 模型A) → Pass 2 (Phase 2+, 模型B, resume 同一 trace)。
  778. main_provider, main_model = args.provider, args.model
  779. p1_provider = args.phase1_provider or args.provider
  780. print(f"\n{'='*64}\n Pass 1/2 · Phase 1 only · {p1_provider}/{args.phase1_model}\n{'='*64}", flush=True)
  781. args.provider, args.model = p1_provider, args.phase1_model
  782. args.resume, args.phase1_only, args._phase2_handoff = False, True, False
  783. rc1 = asyncio.run(run(args))
  784. stats1 = dict(args._last_stats)
  785. if rc1 != 0:
  786. print(f"❌ Pass 1 退出码={rc1}, 不继续 Phase 2。", file=sys.stderr)
  787. sys.exit(rc1)
  788. print(f"\n{'='*64}\n Pass 2/2 · Phase 2+ (resume) · {main_provider}/{main_model}\n{'='*64}", flush=True)
  789. args.provider, args.model = main_provider, main_model
  790. args.resume, args.phase1_only, args._phase2_handoff = True, False, True
  791. rc2 = asyncio.run(run(args))
  792. stats2 = dict(args._last_stats)
  793. # 两段成本汇总
  794. print(f"\n{'='*64}\n 两段式成本汇总 (case {args.case_id})\n{'='*64}", flush=True)
  795. print(f" Pass1 [{args.phase1_model}]: in={_g(stats1,'in'):,} out={_g(stats1,'out'):,} · ${_g(stats1,'cost'):.4f}")
  796. print(f" Pass2 [{main_model}]: in={_g(stats2,'in'):,} out={_g(stats2,'out'):,} · ${_g(stats2,'cost'):.4f}")
  797. print(f" 合计: ${_g(stats1,'cost') + _g(stats2,'cost'):.4f} (不含子 Agent)", flush=True)
  798. sys.exit(rc2)
  799. if __name__ == "__main__":
  800. main()