run_prompt.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. """
  2. 通用 prompt 执行脚本:输入若干文件 + 一段 prompt 字符串,调用 LLM 输出到 txt。
  3. 四种 mode(默认 claude-agent):
  4. - claude-agent → **Agent 模式**(默认):开放 Read/Grep/Glob 工具,LLM 自己按需读文件
  5. 适合处理超 context window 的大文件 — LLM 不会一次性读完整文件,
  6. 而是用工具分片读 / 关键词检索后再读 — 类似 claude.ai 网页处理大文件的方式
  7. - openrouter → OpenRouter HTTP(一次性调用,文件全文塞 prompt,受 context window 限制)
  8. - claude-sdk → Claude Agent SDK / OAuth 单次调用(不开工具,与其它一次性 mode 等价)
  9. - anthropic → Anthropic 原生 API(一次性调用)
  10. 用法:
  11. # 1) 默认 claude-agent — 直接给文件路径,LLM 自己 Read / Grep
  12. python run_prompt.py --prompt "在这个 JSON 里找 X 相关的条目" --file large.json --output out.txt
  13. # 2) 强制走一次性调用(小文件、想要确定性输出时)
  14. python run_prompt.py --mode openrouter --prompt "总结以下文件" --file a.json --output out.txt
  15. # 3) Prompt 从 stdin
  16. Get-Content prompt.txt | python run_prompt.py --prompt - --file a.json
  17. 脚本通过探测 .git/pyproject.toml 自动定位项目根,可以放在仓库内任意位置。
  18. """
  19. import argparse
  20. import asyncio
  21. import json
  22. import os
  23. import sys
  24. from datetime import datetime
  25. from pathlib import Path
  26. from typing import Any, Callable, Dict, List, Optional
  27. # Windows 控制台 UTF-8
  28. for _s in (sys.stdout, sys.stderr):
  29. try:
  30. _s.reconfigure(encoding="utf-8")
  31. except (AttributeError, OSError):
  32. pass
  33. # 智能探测项目根:沿父目录上爬,找到含 .git / pyproject.toml 的目录。
  34. # 这样脚本无论放在 scratch/ 还是 examples/process_pipeline/scratch/ 都能正确工作。
  35. def _find_project_root(start: Path) -> Path:
  36. p = start.resolve()
  37. for ancestor in [p, *p.parents]:
  38. if (ancestor / ".git").exists() or (ancestor / "pyproject.toml").exists():
  39. return ancestor
  40. return start.resolve().parent
  41. PROJECT_ROOT = _find_project_root(Path(__file__))
  42. SCRIPT_DIR = Path(__file__).resolve().parent # agent mode 的默认工作目录
  43. sys.path.insert(0, str(PROJECT_ROOT))
  44. from dotenv import load_dotenv
  45. load_dotenv(PROJECT_ROOT / ".env")
  46. # 所有输出都落在脚本目录下的 outputs/ 沙盒里。
  47. # 把脚本/数据/输出物理隔离,方便 .gitignore 也方便定位结果。
  48. OUTPUTS_DIR = SCRIPT_DIR / "outputs"
  49. def default_output_path(mode: str) -> Path:
  50. """不传 --output 时的默认输出文件:outputs/ 下带时间戳的 txt,避免覆盖。"""
  51. ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  52. return OUTPUTS_DIR / f"result_{mode}_{ts}.txt"
  53. def resolve_user_output(rel_path: str) -> Path:
  54. """
  55. 把用户的 --output 解析到 OUTPUTS_DIR 下。
  56. - 必须是相对路径
  57. - 解析后必须落在 OUTPUTS_DIR 之内(防 `..` 越界)
  58. """
  59. p = Path(rel_path)
  60. if p.is_absolute():
  61. raise SystemExit(
  62. f"ERROR: --output 必须是相对路径(相对 outputs/),不能是绝对路径: {rel_path!r}"
  63. )
  64. target = (OUTPUTS_DIR / p).resolve()
  65. outputs_root = OUTPUTS_DIR.resolve()
  66. try:
  67. target.relative_to(outputs_root)
  68. except ValueError:
  69. raise SystemExit(
  70. f"ERROR: --output 解析后必须仍在 outputs/ 内,但 {rel_path!r} 越界到了 "
  71. f"{target}(不允许使用 '..' 跳出沙盒)"
  72. )
  73. return target
  74. # ───────────────────────────── LLM mode factories ────────────────────────────
  75. DEFAULT_MODELS = {
  76. "openrouter": "claude-sonnet-4-6",
  77. "claude-sdk": "claude-sonnet-4-6",
  78. "anthropic": "claude-sonnet-4-5",
  79. "claude-agent": "claude-sonnet-4-6", # Agent 模式:开放工具按需读文件
  80. }
  81. # 每个模型族的输出 token 上限(来自 Anthropic/OpenAI 文档)。
  82. # 按 substring 匹配模型名,第一个命中的为准;未命中走 _DEFAULT_MAX_OUTPUT。
  83. _MAX_OUTPUT_TOKENS = [
  84. # Claude 4.x — Anthropic 公布的输出上限
  85. ("sonnet-4", 64000),
  86. ("opus-4", 32000),
  87. ("haiku-4", 8192),
  88. # Claude 3.5 / 3.7
  89. ("sonnet-3-5", 8192),
  90. ("sonnet-3.5", 8192),
  91. ("haiku-3", 4096),
  92. ("opus-3", 4096),
  93. # OpenRouter 上 OpenAI / Google 常见模型
  94. ("gpt-5", 16384),
  95. ("gpt-4", 16384),
  96. ("gemini", 8192),
  97. ("deepseek", 8192),
  98. ]
  99. _DEFAULT_MAX_OUTPUT = 8192
  100. def resolve_max_output_tokens(model: str) -> int:
  101. """按模型名前缀匹配输出 token 上限,未匹配回退默认值。"""
  102. m = model.lower()
  103. for key, cap in _MAX_OUTPUT_TOKENS:
  104. if key in m:
  105. return cap
  106. return _DEFAULT_MAX_OUTPUT
  107. def build_llm_call(mode: str, model: str) -> Callable:
  108. """
  109. 根据 mode 实例化 llm_call。三种一次性 mode 都返回相同契约的 async 函数:
  110. async (messages, model=..., **kwargs) -> {"content": str, "usage": {...}}
  111. (claude-agent 模式不走这里,单独分叉到 run_claude_agent_mode。)
  112. """
  113. if mode == "openrouter":
  114. from agent.llm.openrouter import create_openrouter_llm_call
  115. return create_openrouter_llm_call(model=model)
  116. if mode == "claude-sdk":
  117. from agent.llm.claude_code_oauth import create_claude_code_oauth_llm_call
  118. return create_claude_code_oauth_llm_call(model=model)
  119. if mode == "anthropic":
  120. from agent.llm.claude import create_claude_llm_call
  121. return create_claude_llm_call(model=model)
  122. raise ValueError(f"Unknown mode: {mode!r}. Choose from {list(DEFAULT_MODELS)}")
  123. # ────────────────────────────── prompt assembly ──────────────────────────────
  124. def read_prompt(prompt_arg: Optional[str], prompt_file: Optional[str]) -> str:
  125. """从 --prompt / --prompt-file / stdin 三选一拿 prompt 字符串。"""
  126. if prompt_file:
  127. return Path(prompt_file).read_text(encoding="utf-8").strip()
  128. if prompt_arg == "-":
  129. return sys.stdin.read().strip()
  130. if prompt_arg:
  131. return prompt_arg
  132. raise SystemExit("ERROR: must provide --prompt TEXT, --prompt - (stdin), or --prompt-file PATH")
  133. def read_file_for_prompt(path: str) -> str:
  134. """读单个文件内容。大文件不做客户端预检 —— 信任 LLM 端的报错。"""
  135. p = Path(path)
  136. if not p.exists():
  137. raise FileNotFoundError(f"File not found: {path}")
  138. return p.read_text(encoding="utf-8", errors="replace")
  139. def assemble_prompt(prompt: str, files: List[str]) -> str:
  140. """拼接最终 prompt:用户 prompt 在前,每个文件用 `=== file: <name> ===` 分隔附在后面。"""
  141. if not files:
  142. return prompt
  143. blocks = [prompt.rstrip(), ""]
  144. for path in files:
  145. content = read_file_for_prompt(path)
  146. blocks.append(f"=== file: {path} ({len(content):,} chars) ===")
  147. blocks.append(content)
  148. blocks.append("")
  149. return "\n".join(blocks).rstrip() + "\n"
  150. # ─────────────────────────────── response handling ───────────────────────────
  151. _TRUNCATION_REASONS = {"length", "max_tokens", "MAX_TOKENS"}
  152. def extract_text_and_usage(response: Dict[str, Any]) -> tuple:
  153. """从 llm_call 返回值抽 content / usage / finish_reason。三个一次性 mode 契约一致。"""
  154. content = response.get("content", "")
  155. if isinstance(content, list):
  156. parts = []
  157. for block in content:
  158. if isinstance(block, dict):
  159. parts.append(block.get("text") or "")
  160. else:
  161. parts.append(str(block))
  162. content = "".join(parts)
  163. elif not isinstance(content, str):
  164. content = str(content)
  165. usage = response.get("usage") or {}
  166. if hasattr(usage, "__dict__") and not isinstance(usage, dict):
  167. usage = {k: getattr(usage, k) for k in dir(usage)
  168. if not k.startswith("_") and not callable(getattr(usage, k))}
  169. in_tok = usage.get("input_tokens") or usage.get("prompt_tokens") or 0
  170. out_tok = usage.get("output_tokens") or usage.get("completion_tokens") or 0
  171. finish_reason = response.get("finish_reason") or response.get("stop_reason")
  172. return content, {
  173. "input_tokens": in_tok,
  174. "output_tokens": out_tok,
  175. "finish_reason": finish_reason,
  176. "raw": usage,
  177. }
  178. # ────────────────────────── claude-agent mode (tools) ───────────────────────
  179. DEFAULT_AGENT_TOOLS = ["Read", "Grep", "Glob"]
  180. async def run_claude_agent_mode(args: argparse.Namespace, prompt: str, files: List[str]) -> int:
  181. """
  182. Agent 模式:用 ClaudeSDKClient 开放工具,让 LLM 自己 Read/Grep 文件。
  183. 与其它三个 mode 的根本区别:不把文件全文塞 prompt,而是把"路径 + 工具能力"给 LLM。
  184. 适合处理超 context window 的大文件。
  185. 实现参考 agent/llm/claude_code_oauth.py,但关键区别:
  186. - allowed_tools 开放(而非 [])
  187. - max_turns > 1(而非 1)
  188. - cwd 设到脚本所在目录(让相对路径文件能直接被 Read)
  189. """
  190. try:
  191. from claude_agent_sdk import (
  192. AssistantMessage,
  193. ClaudeAgentOptions,
  194. ClaudeSDKClient,
  195. ClaudeSDKError,
  196. ResultMessage,
  197. TextBlock,
  198. )
  199. except ImportError as e:
  200. print(f"!!! ERROR: claude_agent_sdk not installed: {e}", file=sys.stderr)
  201. print("!!! pip install claude-agent-sdk", file=sys.stderr)
  202. return 1
  203. # 抹掉 API key 让 SDK 走 OAuth(复用 claude_code_oauth.py 的处理)
  204. override_env = {
  205. "ANTHROPIC_API_KEY": "",
  206. "ANTHROPIC_BASE_URL": "",
  207. "ANTHROPIC_AUTH_TOKEN": "",
  208. }
  209. # 构造 prompt:把文件路径作为引用而非内联全文
  210. if files:
  211. abs_files = [str(Path(f).resolve()) for f in files]
  212. file_listing = "\n".join(f"- {p}" for p in abs_files)
  213. full_prompt = (
  214. f"{prompt.rstrip()}\n\n"
  215. f"---\n"
  216. f"可用文件(用 Read/Grep/Glob 工具按需读取,**不要**一次性读完整文件):\n"
  217. f"{file_listing}\n\n"
  218. f"## 工具使用规则(重要 — 违反会导致 SDK 子进程 crash)\n\n"
  219. f"SDK 子进程的 stdin/stdout JSON 消息**硬上限是 1MB**,单次工具调用返回数据"
  220. f"超过该上限会让整个 agent 进程崩溃。所以:\n\n"
  221. f"- **Read**:单次 `limit` 不要超过 500 行;大文件请多次 Read 用 `offset` 翻页。\n"
  222. f"- **Grep**:必须显式设 `head_limit`(≤ 200);**永远不要**设 `head_limit=0`"
  223. f"(在 SDK 里等同于无限制)。如果只是想知道有没有命中,用 "
  224. f"`output_mode=\"files_with_matches\"` 或 `output_mode=\"count\"`。\n"
  225. f"- **Glob**:返回的文件列表自然受 head_limit 控制,但避免对大目录用 `**/*` 等过宽的 pattern。\n"
  226. f"- 工作策略:先小范围探测(结构、字段、行数),再有针对性地读局部 — 不要试图把全文倒进 context。\n"
  227. )
  228. else:
  229. full_prompt = prompt
  230. allowed_tools = args.allowed_tools or DEFAULT_AGENT_TOOLS
  231. max_turns = args.max_turns
  232. stderr_lines: List[str] = []
  233. def _capture_stderr(line: str) -> None:
  234. if line:
  235. stderr_lines.append(line)
  236. options = ClaudeAgentOptions(
  237. model=args.model,
  238. allowed_tools=allowed_tools,
  239. max_turns=max_turns,
  240. cwd=str(SCRIPT_DIR), # 工具的工作目录设为脚本所在目录
  241. env=override_env,
  242. stderr=_capture_stderr,
  243. setting_sources=[], # 屏蔽用户级 ~/.claude/ 配置注入
  244. )
  245. print(
  246. f"[info] mode=claude-agent model={args.model} "
  247. f"allowed_tools={allowed_tools} max_turns={max_turns}",
  248. file=sys.stderr,
  249. )
  250. print(f"[info] cwd={SCRIPT_DIR}", file=sys.stderr)
  251. print(f"[info] prompt: {len(full_prompt):,} chars files={len(files)}", file=sys.stderr)
  252. if args.show_prompt:
  253. print("─── assembled prompt ───", file=sys.stderr)
  254. print(full_prompt, file=sys.stderr)
  255. print("─── end prompt ───", file=sys.stderr)
  256. text_parts: List[str] = []
  257. usage: Dict[str, Any] = {}
  258. is_error = False
  259. result_subtype: Optional[str] = None
  260. result_errors: List[str] = []
  261. try:
  262. async with ClaudeSDKClient(options=options) as client:
  263. await client.query(full_prompt)
  264. async for msg in client.receive_response():
  265. msg_type = type(msg).__name__
  266. if isinstance(msg, AssistantMessage):
  267. for block in msg.content:
  268. if hasattr(block, "thinking"):
  269. continue # thinking 内容跳过
  270. elif isinstance(block, TextBlock):
  271. text_parts.append(block.text)
  272. preview = block.text.replace("\n", " ")[:160]
  273. print(f"[agent text] {preview}", file=sys.stderr)
  274. elif hasattr(block, "name") and hasattr(block, "input"):
  275. tool_input_str = json.dumps(
  276. block.input, ensure_ascii=False
  277. )[:240]
  278. print(
  279. f"[agent tool_use] {block.name}({tool_input_str})",
  280. file=sys.stderr,
  281. )
  282. else:
  283. print(
  284. f"[agent {type(block).__name__}] {block!r}"[:240],
  285. file=sys.stderr,
  286. )
  287. elif isinstance(msg, ResultMessage):
  288. if msg.usage:
  289. usage = dict(msg.usage)
  290. is_error = msg.is_error
  291. result_subtype = msg.subtype
  292. result_errors = list(msg.errors or [])
  293. print(
  294. f"[info] agent done: turns={msg.num_turns} "
  295. f"duration={msg.duration_ms}ms "
  296. f"in={usage.get('input_tokens', 0)} "
  297. f"out={usage.get('output_tokens', 0)} "
  298. f"is_error={is_error}",
  299. file=sys.stderr,
  300. )
  301. elif msg_type == "SystemMessage":
  302. subtype = getattr(msg, "subtype", "?")
  303. print(f"[agent system] subtype={subtype}", file=sys.stderr)
  304. except ClaudeSDKError as e:
  305. import traceback
  306. print("\n" + "!" * 78, file=sys.stderr)
  307. print(f"!!! ClaudeSDKError: {type(e).__name__}: {e}", file=sys.stderr)
  308. if stderr_lines:
  309. print("!!! CLI stderr (last 20 lines):", file=sys.stderr)
  310. for line in stderr_lines[-20:]:
  311. print(line, file=sys.stderr)
  312. print("!" * 78, file=sys.stderr)
  313. traceback.print_exc(file=sys.stderr)
  314. return 1
  315. if is_error:
  316. print(
  317. f"\n!!! agent reported is_error=True subtype={result_subtype} "
  318. f"errors={result_errors}",
  319. file=sys.stderr,
  320. )
  321. return 1
  322. content = "".join(text_parts).strip()
  323. _write_output(content, args, mode="claude-agent")
  324. return 0
  325. def _write_output(content: str, args: argparse.Namespace, mode: str) -> None:
  326. """统一输出处理:默认写 outputs/result_<mode>_<ts>.txt,--stdout 才打 stdout。"""
  327. if args.stdout:
  328. print(content)
  329. return
  330. out_path = resolve_user_output(args.output) if args.output else default_output_path(mode)
  331. out_path.parent.mkdir(parents=True, exist_ok=True)
  332. out_path.write_text(content, encoding="utf-8")
  333. print(
  334. f"[info] written to {out_path} ({len(content)} chars)",
  335. file=sys.stderr,
  336. )
  337. # ─────────────────────────────────── main ────────────────────────────────────
  338. async def run(args: argparse.Namespace) -> int:
  339. """
  340. Return codes:
  341. 0 — success
  342. 1 — LLM call or other runtime error (full traceback + server body printed)
  343. 2 — output truncated (hit max_tokens); partial result still written
  344. """
  345. # Fail-fast:先校验 --output 合法性,免得 agent 跑 5 分钟才报路径错
  346. if args.output and not args.stdout:
  347. resolve_user_output(args.output) # 不合法会 SystemExit
  348. OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)
  349. prompt = read_prompt(args.prompt, args.prompt_file)
  350. # Agent 模式:分叉到 ClaudeSDKClient 路径(不走 build_llm_call)
  351. if args.mode == "claude-agent":
  352. return await run_claude_agent_mode(args, prompt, args.file or [])
  353. full_prompt = assemble_prompt(prompt, args.file or [])
  354. # max_tokens: 默认按 model 取该模型族的最大输出 token
  355. effective_max_tokens = args.max_tokens or resolve_max_output_tokens(args.model)
  356. print(
  357. f"[info] mode={args.mode} model={args.model} "
  358. f"max_tokens={effective_max_tokens}",
  359. file=sys.stderr,
  360. )
  361. print(
  362. f"[info] prompt: {len(full_prompt):,} chars files={len(args.file or [])}",
  363. file=sys.stderr,
  364. )
  365. if args.show_prompt:
  366. print("─── assembled prompt ───", file=sys.stderr)
  367. print(full_prompt, file=sys.stderr)
  368. print("─── end prompt ───", file=sys.stderr)
  369. llm_call = build_llm_call(args.mode, args.model)
  370. messages = [{"role": "user", "content": full_prompt}]
  371. call_kwargs: Dict[str, Any] = {
  372. "messages": messages,
  373. "model": args.model,
  374. "temperature": args.temperature,
  375. "max_tokens": effective_max_tokens,
  376. }
  377. try:
  378. response = await llm_call(**call_kwargs)
  379. except Exception as e:
  380. import traceback
  381. print("\n" + "!" * 78, file=sys.stderr)
  382. print(f"!!! LLM CALL FAILED: {type(e).__name__}: {e}", file=sys.stderr)
  383. # httpx HTTPStatusError 等会带 response.text — 服务端的错误 body 才有用
  384. for attr in ("response", "body"):
  385. obj = getattr(e, attr, None)
  386. if obj is not None:
  387. try:
  388. text = obj.text if hasattr(obj, "text") else str(obj)
  389. status = getattr(obj, "status_code", None)
  390. if status is not None:
  391. print(f"!!! server HTTP {status} body:", file=sys.stderr)
  392. else:
  393. print(f"!!! server {attr}:", file=sys.stderr)
  394. print(text[:4000], file=sys.stderr)
  395. except Exception:
  396. pass
  397. print("!" * 78, file=sys.stderr)
  398. traceback.print_exc(file=sys.stderr)
  399. return 1
  400. content, usage = extract_text_and_usage(response)
  401. finish_reason = usage["finish_reason"]
  402. print(
  403. f"[info] usage: in={usage['input_tokens']} out={usage['output_tokens']} "
  404. f"finish_reason={finish_reason!r}",
  405. file=sys.stderr,
  406. )
  407. truncated = finish_reason in _TRUNCATION_REASONS
  408. if truncated:
  409. print("", file=sys.stderr)
  410. print("!" * 78, file=sys.stderr)
  411. print(
  412. f"!!! WARNING: OUTPUT TRUNCATED (finish_reason={finish_reason!r}, "
  413. f"output_tokens={usage['output_tokens']} reached max_tokens={effective_max_tokens})",
  414. file=sys.stderr,
  415. )
  416. print(
  417. f"!!! 模型还想继续输出但被 max_tokens 截断了。要拿完整输出,请:",
  418. file=sys.stderr,
  419. )
  420. print(
  421. f"!!! 1) 提高 --max-tokens(当前模型 {args.model} 的理论上限为 "
  422. f"{resolve_max_output_tokens(args.model)})",
  423. file=sys.stderr,
  424. )
  425. print(
  426. f"!!! 2) 或缩小输入 / 拆分任务 / 让 prompt 要求更简洁的回答",
  427. file=sys.stderr,
  428. )
  429. print("!" * 78, file=sys.stderr)
  430. print("", file=sys.stderr)
  431. _write_output(content, args, mode=args.mode)
  432. return 2 if truncated else 0
  433. def build_parser() -> argparse.ArgumentParser:
  434. p = argparse.ArgumentParser(
  435. description="把若干文件附在 prompt 后面发给 LLM,结果写入 txt 文件。",
  436. formatter_class=argparse.RawDescriptionHelpFormatter,
  437. )
  438. p.add_argument(
  439. "--mode", default="claude-agent", choices=list(DEFAULT_MODELS),
  440. help="LLM 调用方式(默认 claude-agent):claude-agent | openrouter | claude-sdk | anthropic。"
  441. " claude-agent 开放 Read/Grep/Glob 工具让 LLM 自己按需读文件,能处理大文件;"
  442. " 其它三种是一次性调用,文件全文塞 prompt(受 context window 限制)。",
  443. )
  444. p.add_argument(
  445. "--model", default=None,
  446. help=f"模型名。各 mode 默认值:{DEFAULT_MODELS}",
  447. )
  448. p.add_argument("--prompt", help="prompt 字符串。传 '-' 从 stdin 读")
  449. p.add_argument("--prompt-file", help="从文件读 prompt(与 --prompt 二选一)")
  450. p.add_argument("--file", action="append", help="附加到 prompt 后的输入文件,可多次传")
  451. p.add_argument(
  452. "--output",
  453. help="输出文件路径,**只能是相对路径**,相对 <脚本目录>/outputs/ 解析;"
  454. " 绝对路径或 '..' 越界会被拒绝。"
  455. " 不传则自动写到 outputs/result_<mode>_<timestamp>.txt。"
  456. " 用 --stdout 可强制走 stdout 而非文件。",
  457. )
  458. p.add_argument(
  459. "--stdout", action="store_true",
  460. help="强制把结果打到 stdout 而不是文件(旧默认行为)",
  461. )
  462. p.add_argument("--temperature", type=float, default=0.1)
  463. p.add_argument(
  464. "--max-tokens", type=int, default=None,
  465. help="最大输出 token 数。不传则按 model 自动取上限(sonnet-4→64K, opus-4→32K, "
  466. "haiku-4→8K, gpt-5→16K, 其他→8K)。仅对 openrouter/anthropic mode 有效。",
  467. )
  468. p.add_argument("--show-prompt", action="store_true",
  469. help="把拼好的完整 prompt 也打到 stderr,方便调试")
  470. # ── claude-agent mode 专用参数 ──
  471. p.add_argument(
  472. "--allowed-tools", action="append",
  473. help="agent mode 允许使用的工具,可多次传。默认 Read/Grep/Glob。"
  474. " 可选:Read, Grep, Glob, Bash, Edit, Write, WebFetch, WebSearch",
  475. )
  476. p.add_argument(
  477. "--max-turns", type=int, default=30,
  478. help="agent mode 最大对话轮数(默认 30)",
  479. )
  480. return p
  481. def main():
  482. """全局兜底:任何未捕获异常都打 traceback + 非零退码,避免出现孤儿 exit code。"""
  483. try:
  484. args = build_parser().parse_args()
  485. if args.model is None:
  486. args.model = DEFAULT_MODELS[args.mode]
  487. code = asyncio.run(run(args))
  488. sys.exit(code)
  489. except KeyboardInterrupt:
  490. print("\n[info] interrupted by user (Ctrl+C)", file=sys.stderr)
  491. sys.exit(130)
  492. except SystemExit:
  493. raise
  494. except BaseException as e:
  495. import traceback
  496. print(f"\n!!! UNEXPECTED ERROR: {type(e).__name__}: {e}", file=sys.stderr)
  497. traceback.print_exc(file=sys.stderr)
  498. sys.exit(1)
  499. if __name__ == "__main__":
  500. main()