codebee 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- package/package.json +39 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""运行器:把各家 CLI 的无头调用统一成一个接口。
|
|
3
|
+
|
|
4
|
+
实测验证过的接入方式(2026-09,本机):
|
|
5
|
+
codex : codex exec --skip-git-repo-check --json -s <sandbox> [prompt从stdin]
|
|
6
|
+
stdout 为 JSONL 事件流;item.completed(type=agent_message)=最终回答,
|
|
7
|
+
turn.completed=token 用量。pnpm 的 .cmd 垫片需 cmd /c 包装。
|
|
8
|
+
claude : claude -p --output-format json [prompt从stdin]
|
|
9
|
+
stdout 为单个 JSON:result / total_cost_usd / usage。
|
|
10
|
+
需环境变量 CLAUDE_CODE_GIT_BASH_PATH(原生 exe 找不到 bash 会拒绝启动)
|
|
11
|
+
与 CLAUDE_CODE_MAX_OUTPUT_TOKENS(自定义网关模型常见 32768 上限)。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from .env_scrub import scrub_env
|
|
24
|
+
from .error_codes import ErrorCode
|
|
25
|
+
|
|
26
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
27
|
+
DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
|
|
28
|
+
|
|
29
|
+
_BASH_CANDIDATES = [
|
|
30
|
+
r"D:\Git\usr\bin\bash.exe",
|
|
31
|
+
r"C:\Program Files\Git\usr\bin\bash.exe",
|
|
32
|
+
r"C:\Program Files (x86)\Git\usr\bin\bash.exe",
|
|
33
|
+
]
|
|
34
|
+
_bash_cache = {"path": None, "done": False}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def find_git_bash():
|
|
38
|
+
if _bash_cache["done"]:
|
|
39
|
+
return _bash_cache["path"]
|
|
40
|
+
_bash_cache["done"] = True
|
|
41
|
+
p = shutil.which("bash.exe") or shutil.which("bash")
|
|
42
|
+
if p:
|
|
43
|
+
_bash_cache["path"] = p
|
|
44
|
+
return p
|
|
45
|
+
for cand in _BASH_CANDIDATES:
|
|
46
|
+
if os.path.isfile(cand):
|
|
47
|
+
_bash_cache["path"] = cand
|
|
48
|
+
return cand
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_command(command):
|
|
53
|
+
"""命令名 → 可直接 spawn 的 argv 前缀(.cmd/.bat 垫片必须经 cmd /c)。"""
|
|
54
|
+
path = shutil.which(command) or command
|
|
55
|
+
low = path.lower()
|
|
56
|
+
if low.endswith(".cmd") or low.endswith(".bat"):
|
|
57
|
+
return ["cmd", "/c", path]
|
|
58
|
+
return [path]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _kill_tree(pid):
|
|
62
|
+
try:
|
|
63
|
+
subprocess.run(
|
|
64
|
+
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
65
|
+
capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=15)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _drain_streams(proc, t_out, t_err, *, timeout=10):
|
|
71
|
+
"""kill_tree 后等管道线程读完剩余字节。
|
|
72
|
+
|
|
73
|
+
daemon=True 线程 join 超时即结束(不会卡死主流程)。
|
|
74
|
+
设计稿:docs/migration/01-defense-patterns.md §5B。
|
|
75
|
+
参考 dsh docs/defensive-patterns.zh.md 第 21 行("dispose 必须达到完全停稳")。
|
|
76
|
+
"""
|
|
77
|
+
deadline = time.time() + timeout
|
|
78
|
+
for t in (t_out, t_err):
|
|
79
|
+
remaining = max(0.0, deadline - time.time())
|
|
80
|
+
if remaining > 0:
|
|
81
|
+
t.join(timeout=remaining)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _pipe_reader(stream, chunks, log_fh):
|
|
85
|
+
"""持续读子进程管道并实时落盘。
|
|
86
|
+
|
|
87
|
+
必须用 read1():BufferedReader.read(n) 会阻塞到凑满 n 字节或 EOF,
|
|
88
|
+
在长命令(npm 安装等)上等于"进程结束才一次性返回",日志面板全程空白。
|
|
89
|
+
read1() 只要有数据就返回,日志才能真正边跑边看。
|
|
90
|
+
"""
|
|
91
|
+
while True:
|
|
92
|
+
b = stream.read1(65536)
|
|
93
|
+
if not b:
|
|
94
|
+
break
|
|
95
|
+
chunks.append(b)
|
|
96
|
+
if log_fh:
|
|
97
|
+
try:
|
|
98
|
+
log_fh.write(b)
|
|
99
|
+
log_fh.flush()
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def decode_output(data):
|
|
105
|
+
"""子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。"""
|
|
106
|
+
if not data:
|
|
107
|
+
return ""
|
|
108
|
+
try:
|
|
109
|
+
return data.decode("utf-8")
|
|
110
|
+
except UnicodeDecodeError:
|
|
111
|
+
pass
|
|
112
|
+
try:
|
|
113
|
+
return data.decode("gbk")
|
|
114
|
+
except UnicodeDecodeError:
|
|
115
|
+
return data.decode("utf-8", "replace")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_text_any_enc(path):
|
|
119
|
+
"""读文本文件,编码纪律同 decode_output(UTF-8 严格 → GBK 回退 → replace 兜底)。
|
|
120
|
+
|
|
121
|
+
工作区文件由 CLI 子代理落盘,中文 Windows 上 PowerShell Set-Content 缺省写
|
|
122
|
+
GBK;消费侧(章节/diff/故事圣经)统一走本函数,不再因编码混编出 U+FFFD。
|
|
123
|
+
文件不存在/读失败抛 OSError,由调用方兜底(与 open 语义一致)。"""
|
|
124
|
+
with open(path, "rb") as f:
|
|
125
|
+
data = f.read()
|
|
126
|
+
return decode_output(data)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def tail_decoded(data, tail):
|
|
130
|
+
"""按字节取尾部再解码;切片可能落在 UTF-8 多字节字符中间,先丢弃开头的
|
|
131
|
+
continuation 字节(10xxxxxx,至多 3 个)对齐字符边界,否则残缺字节会被
|
|
132
|
+
GBK 回退解码成乱码字符。"""
|
|
133
|
+
if not data:
|
|
134
|
+
return ""
|
|
135
|
+
chunk = data[-tail:] if tail and len(data) > tail else data
|
|
136
|
+
i = 0
|
|
137
|
+
while i < len(chunk) and i < 3 and (chunk[i] & 0xC0) == 0x80:
|
|
138
|
+
i += 1
|
|
139
|
+
return decode_output(chunk[i:])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_TS_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}T[\d:.,]+Z?\s*")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def collapse_dup_lines(text, max_group=4):
|
|
146
|
+
"""折叠「归一化后重复」的邻近行,保留每种首行原文并标注折叠数。
|
|
147
|
+
|
|
148
|
+
真实案例:codex_otel 遥测对网关自定义模型名(如 [opencode]xxx,方括号是
|
|
149
|
+
非法 OTel tag 字符)每个 SSE 事件刷一对 WARN——counter/duration 两种文案
|
|
150
|
+
交替出现,且每条带微秒时间戳:剥掉时间戳前缀后相邻行仍不相等,必须按
|
|
151
|
+
「邻近组」折叠——组内至多 max_group 个键,新键只有组满才结算上一组,
|
|
152
|
+
这样交替刷屏的各个文案都归入同一组(计数各自累计)。空行先结算所在组
|
|
153
|
+
再原样保留。超过组宽的零散重复(中间隔着足量新行)不受影响。
|
|
154
|
+
"""
|
|
155
|
+
if not text:
|
|
156
|
+
return ""
|
|
157
|
+
out = []
|
|
158
|
+
keys, first, cnt = [], {}, {}
|
|
159
|
+
|
|
160
|
+
def _flush():
|
|
161
|
+
for k in keys:
|
|
162
|
+
out.append(first[k])
|
|
163
|
+
if cnt[k] > 1:
|
|
164
|
+
out.append("⋯(上行重复 ×%d 已折叠)" % (cnt[k] - 1))
|
|
165
|
+
keys.clear()
|
|
166
|
+
first.clear()
|
|
167
|
+
cnt.clear()
|
|
168
|
+
|
|
169
|
+
for ln in text.splitlines():
|
|
170
|
+
k = _TS_PREFIX.sub("", ln).strip()
|
|
171
|
+
if not k:
|
|
172
|
+
_flush() # 先结算扣住的组,保证空行前后顺序不失真
|
|
173
|
+
out.append(ln)
|
|
174
|
+
continue
|
|
175
|
+
if k in cnt:
|
|
176
|
+
cnt[k] += 1
|
|
177
|
+
elif len(keys) >= max_group:
|
|
178
|
+
_flush()
|
|
179
|
+
keys.append(k)
|
|
180
|
+
first[k] = ln
|
|
181
|
+
cnt[k] = 1
|
|
182
|
+
else:
|
|
183
|
+
keys.append(k)
|
|
184
|
+
first[k] = ln
|
|
185
|
+
cnt[k] = 1
|
|
186
|
+
_flush()
|
|
187
|
+
if text.endswith("\n"):
|
|
188
|
+
out.append("")
|
|
189
|
+
return "\n".join(out)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _fmt_codex_item(it, cap):
|
|
193
|
+
t = it.get("type") or ""
|
|
194
|
+
if t == "agent_message":
|
|
195
|
+
return "【消息】" + str(it.get("text") or "")[:cap]
|
|
196
|
+
if t == "reasoning":
|
|
197
|
+
return "【思考】" + str(it.get("text") or "")[:cap]
|
|
198
|
+
if t == "command_execution":
|
|
199
|
+
line = "【命令】%s(退出码 %s)" % (it.get("command") or "?", it.get("exit_code", "?"))
|
|
200
|
+
outp = str(it.get("aggregated_output") or "").strip()
|
|
201
|
+
if outp:
|
|
202
|
+
line += "\n | " + outp[:600].replace("\n", "\n | ")
|
|
203
|
+
return line
|
|
204
|
+
if t == "file_change":
|
|
205
|
+
names = ", ".join(str(c.get("path") or "?") for c in (it.get("changes") or [])
|
|
206
|
+
if isinstance(c, dict))
|
|
207
|
+
return "【文件改动】" + (names or "?")
|
|
208
|
+
if t == "mcp_tool_call":
|
|
209
|
+
return "【工具】%s %s" % (it.get("tool") or "?",
|
|
210
|
+
json.dumps(it.get("arguments") or "", ensure_ascii=False)[:200])
|
|
211
|
+
if t == "web_search":
|
|
212
|
+
return "【搜索】" + str(it.get("query") or "")
|
|
213
|
+
if t == "error":
|
|
214
|
+
return "【错误】" + str(it.get("message") or "")
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def pretty_cli_log(text, max_event_chars=4000):
|
|
219
|
+
"""codex --json 的 JSONL 事件流 → 人类可读行;非 JSONL 行原样保留。
|
|
220
|
+
|
|
221
|
+
--json 模式下 stdout 全是机器事件(thread/turn/item…),智能体真正在说的
|
|
222
|
+
话被埋在转义 JSON 里;这里逐行翻译成【消息】【思考】【命令】,让日志抽屉
|
|
223
|
+
读到的是「蜂在干什么」。解析失败或未识别的事件类型原样保留,不吞内容。
|
|
224
|
+
"""
|
|
225
|
+
out = []
|
|
226
|
+
for ln in (text or "").splitlines():
|
|
227
|
+
s = ln.strip()
|
|
228
|
+
ev = None
|
|
229
|
+
if s.startswith("{") and s.endswith("}"):
|
|
230
|
+
try:
|
|
231
|
+
ev = json.loads(s)
|
|
232
|
+
except Exception:
|
|
233
|
+
ev = None
|
|
234
|
+
if not isinstance(ev, dict):
|
|
235
|
+
out.append(ln)
|
|
236
|
+
continue
|
|
237
|
+
typ = ev.get("type") or ""
|
|
238
|
+
if typ in ("thread.started", "turn.started"):
|
|
239
|
+
continue
|
|
240
|
+
if typ == "turn.completed":
|
|
241
|
+
u = ev.get("usage") or {}
|
|
242
|
+
out.append("— 一轮完成(tokens 入 %s / 出 %s)—" % (
|
|
243
|
+
u.get("input_tokens", "?"), u.get("output_tokens", "?")))
|
|
244
|
+
continue
|
|
245
|
+
if typ == "turn.failed":
|
|
246
|
+
e = ev.get("error")
|
|
247
|
+
out.append("— 一轮失败:%s —" % (e.get("message") if isinstance(e, dict) else e))
|
|
248
|
+
continue
|
|
249
|
+
if typ in ("item.completed", "item.started", "item.updated"):
|
|
250
|
+
if typ != "item.completed" or not isinstance(ev.get("item"), dict):
|
|
251
|
+
continue # started/updated 是过程噪音,completed 才有内容
|
|
252
|
+
line = _fmt_codex_item(ev["item"], max_event_chars)
|
|
253
|
+
if line:
|
|
254
|
+
out.append(line)
|
|
255
|
+
continue
|
|
256
|
+
out.append(ln)
|
|
257
|
+
return "\n".join(out)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
261
|
+
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None):
|
|
262
|
+
"""通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
|
|
263
|
+
|
|
264
|
+
返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out}。
|
|
265
|
+
"""
|
|
266
|
+
if shell_cmd:
|
|
267
|
+
argv = ["cmd", "/c", shell_cmd]
|
|
268
|
+
if argv is None:
|
|
269
|
+
return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
|
|
270
|
+
"duration": 0.0, "cancelled": False, "timed_out": False}
|
|
271
|
+
full_env = scrub_env(os.environ.copy(), mode="drop")
|
|
272
|
+
if env:
|
|
273
|
+
# 5A:env 关键字环境变量注入用户传入的 env(属于有意注入,例如模型 API key)
|
|
274
|
+
full_env.update({str(k): str(v) for k, v in env.items()})
|
|
275
|
+
log_fh = open(log_path, "ab") if log_path else None
|
|
276
|
+
# 审计:调用下达前先把「执行的命令 + 发给智能体的指令原文」写进日志,
|
|
277
|
+
# 运行中点开步骤就能看到"编排者下了什么令",不用等结束猜。
|
|
278
|
+
# env 绝不写(含 API key);argv 里只有 base_url/env_key 名,无密钥值。
|
|
279
|
+
# 指令超 12000 字符截断(章节正文可能很长),标注原始长度防误读。
|
|
280
|
+
if log_fh:
|
|
281
|
+
try:
|
|
282
|
+
head = ["===== 下达 %s =====" % time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
283
|
+
"$ " + " ".join(str(a) for a in argv)]
|
|
284
|
+
if stdin_text:
|
|
285
|
+
capped = stdin_text[:12000]
|
|
286
|
+
head.append("--- 指令(%d 字符%s)---" % (
|
|
287
|
+
len(stdin_text), ",已截断" if len(stdin_text) > 12000 else ""))
|
|
288
|
+
head.append(capped)
|
|
289
|
+
log_fh.write(("\n".join(head) + "\n--- 输出 ---\n").encode("utf-8", "replace"))
|
|
290
|
+
log_fh.flush()
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
try:
|
|
294
|
+
try:
|
|
295
|
+
proc = subprocess.Popen(
|
|
296
|
+
[str(a) for a in argv], cwd=cwd, env=full_env,
|
|
297
|
+
stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
|
|
298
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
299
|
+
creationflags=CREATE_NO_WINDOW)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
return {"ok": False, "exit_code": None, "stdout": "",
|
|
302
|
+
"stderr": "启动失败: %r" % e, "duration": 0.0,
|
|
303
|
+
"cancelled": False, "timed_out": False}
|
|
304
|
+
out_chunks, err_chunks = [], []
|
|
305
|
+
t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh), daemon=True)
|
|
306
|
+
t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh), daemon=True)
|
|
307
|
+
t_out.start()
|
|
308
|
+
t_err.start()
|
|
309
|
+
if stdin_text is not None:
|
|
310
|
+
def _feed():
|
|
311
|
+
try:
|
|
312
|
+
proc.stdin.write(stdin_text.encode("utf-8"))
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
finally:
|
|
316
|
+
try:
|
|
317
|
+
proc.stdin.close()
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
threading.Thread(target=_feed, daemon=True).start()
|
|
321
|
+
start = time.time()
|
|
322
|
+
cancelled = timed_out = False
|
|
323
|
+
while True:
|
|
324
|
+
try:
|
|
325
|
+
proc.wait(timeout=0.4)
|
|
326
|
+
break
|
|
327
|
+
except subprocess.TimeoutExpired:
|
|
328
|
+
pass
|
|
329
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
330
|
+
cancelled = True
|
|
331
|
+
_kill_tree(proc.pid)
|
|
332
|
+
_drain_streams(proc, t_out, t_err, timeout=10)
|
|
333
|
+
break
|
|
334
|
+
if time.time() - start > timeout:
|
|
335
|
+
timed_out = True
|
|
336
|
+
_kill_tree(proc.pid)
|
|
337
|
+
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
338
|
+
break
|
|
339
|
+
duration = round(time.time() - start, 1)
|
|
340
|
+
t_out.join(timeout=5)
|
|
341
|
+
t_err.join(timeout=5)
|
|
342
|
+
exit_code = proc.returncode
|
|
343
|
+
stdout = decode_output(b"".join(out_chunks))
|
|
344
|
+
stderr = decode_output(b"".join(err_chunks))
|
|
345
|
+
for stream in (proc.stdin, proc.stdout, proc.stderr):
|
|
346
|
+
try:
|
|
347
|
+
if stream:
|
|
348
|
+
stream.close()
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
if cancelled:
|
|
352
|
+
stderr += "\n[已被用户取消]"
|
|
353
|
+
elif timed_out:
|
|
354
|
+
stderr += "\n[超时 %ss,已终止进程树]" % timeout
|
|
355
|
+
return {
|
|
356
|
+
"ok": exit_code == 0 and not cancelled and not timed_out,
|
|
357
|
+
"exit_code": exit_code, "stdout": stdout, "stderr": stderr,
|
|
358
|
+
"duration": duration, "cancelled": cancelled, "timed_out": timed_out,
|
|
359
|
+
}
|
|
360
|
+
finally:
|
|
361
|
+
if log_fh:
|
|
362
|
+
try:
|
|
363
|
+
log_fh.close()
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------- 各家适配
|
|
369
|
+
|
|
370
|
+
def _parse_codex_jsonl(stdout):
|
|
371
|
+
"""解析 codex exec JSONL 事件流。返回 (text, usage, sid)。
|
|
372
|
+
|
|
373
|
+
usage 细分来自 turn.completed:input_tokens(含 cached)、cached_input_tokens、
|
|
374
|
+
output_tokens、reasoning_output_tokens;多 turn 累加。
|
|
375
|
+
sid 来自 thread.started 的 thread_id(§07 T1.1:供后续 revise/fix 复用会话,
|
|
376
|
+
会话内前缀按供应商缓存读计价)。
|
|
377
|
+
"""
|
|
378
|
+
text = ""
|
|
379
|
+
sid = ""
|
|
380
|
+
usage = {"input": 0, "output": 0, "cached": 0, "reasoning": 0, "total": 0}
|
|
381
|
+
for line in stdout.splitlines():
|
|
382
|
+
line = line.strip()
|
|
383
|
+
if not line.startswith("{"):
|
|
384
|
+
continue
|
|
385
|
+
try:
|
|
386
|
+
ev = json.loads(line)
|
|
387
|
+
except Exception:
|
|
388
|
+
continue
|
|
389
|
+
if ev.get("type") == "thread.started":
|
|
390
|
+
sid = str(ev.get("thread_id") or sid)
|
|
391
|
+
elif ev.get("type") == "item.completed":
|
|
392
|
+
item = ev.get("item") or {}
|
|
393
|
+
if item.get("type") == "agent_message" and item.get("text"):
|
|
394
|
+
text = item["text"]
|
|
395
|
+
elif ev.get("type") == "turn.completed":
|
|
396
|
+
u = ev.get("usage") or {}
|
|
397
|
+
inp = int(u.get("input_tokens") or 0)
|
|
398
|
+
out = int(u.get("output_tokens") or 0)
|
|
399
|
+
usage["input"] += inp
|
|
400
|
+
usage["output"] += out
|
|
401
|
+
usage["cached"] += int(u.get("cached_input_tokens") or 0)
|
|
402
|
+
usage["reasoning"] += int(u.get("reasoning_output_tokens") or 0)
|
|
403
|
+
usage["total"] += (u.get("total_tokens") if u.get("total_tokens") is not None
|
|
404
|
+
else inp + out)
|
|
405
|
+
return text, usage, sid
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _codex_fail_msg(stdout):
|
|
409
|
+
"""从 JSONL 事件流提取终态失败消息;无失败返回 ""。
|
|
410
|
+
|
|
411
|
+
2026-09-16 实测:配额/限流只出现在 error 与 turn.failed 事件里,旧解析器
|
|
412
|
+
两者都丢——进程退出码非 0 时错误串里只剩 "Reading prompt from stdin...",
|
|
413
|
+
_quota_error/_transient_error 判不出可降级,健康后继模型从未被尝试。
|
|
414
|
+
Reconnecting... 是 CLI 内部重试噪音(可能自愈),不取;turn.failed 是
|
|
415
|
+
终态优先于裸 error(后者取最后一条兜底)。
|
|
416
|
+
"""
|
|
417
|
+
terminal, last_err = "", ""
|
|
418
|
+
for line in (stdout or "").splitlines():
|
|
419
|
+
line = line.strip()
|
|
420
|
+
if not line.startswith("{"):
|
|
421
|
+
continue
|
|
422
|
+
try:
|
|
423
|
+
ev = json.loads(line)
|
|
424
|
+
except Exception:
|
|
425
|
+
continue
|
|
426
|
+
t = ev.get("type")
|
|
427
|
+
if t == "turn.failed":
|
|
428
|
+
e = ev.get("error")
|
|
429
|
+
terminal = e.get("message") if isinstance(e, dict) else str(e or "")
|
|
430
|
+
elif t == "error":
|
|
431
|
+
m = str(ev.get("message") or "")
|
|
432
|
+
if m and "reconnecting" not in m.lower():
|
|
433
|
+
last_err = m
|
|
434
|
+
return terminal or last_err
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _parse_claude_json(stdout):
|
|
438
|
+
try:
|
|
439
|
+
data = json.loads(stdout)
|
|
440
|
+
except Exception:
|
|
441
|
+
return None
|
|
442
|
+
if not isinstance(data, dict):
|
|
443
|
+
return None
|
|
444
|
+
u = data.get("usage") or {}
|
|
445
|
+
inp = int(u.get("input_tokens") or 0)
|
|
446
|
+
out = int(u.get("output_tokens") or 0)
|
|
447
|
+
# 缓存读 + 缓存写都计入 cached(读是省钱的部分,写是额外消耗的部分)
|
|
448
|
+
cached = int(u.get("cache_read_input_tokens") or 0) + \
|
|
449
|
+
int(u.get("cache_creation_input_tokens") or 0)
|
|
450
|
+
usage = {"input": inp, "output": out, "cached": cached, "reasoning": 0,
|
|
451
|
+
"total": inp + out + cached}
|
|
452
|
+
return {
|
|
453
|
+
"text": data.get("result") or "",
|
|
454
|
+
"cost_usd": data.get("total_cost_usd") or 0.0,
|
|
455
|
+
"usage": usage,
|
|
456
|
+
"tokens": usage["total"],
|
|
457
|
+
"is_error": bool(data.get("is_error")),
|
|
458
|
+
# §07 T1.1:claude -p 返回本次会话 id,供 --resume 复用
|
|
459
|
+
"sid": str(data.get("session_id") or ""),
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _codex_provider_args(cp):
|
|
464
|
+
"""把外部供应商注入为一次性的 codex model_provider(-c 覆盖,不改配置文件)。"""
|
|
465
|
+
name = cp.get("name", "orch")
|
|
466
|
+
return ["-c", 'model_provider="%s"' % name,
|
|
467
|
+
"-c", 'model_providers.%s.name="%s"' % (name, name),
|
|
468
|
+
"-c", 'model_providers.%s.base_url="%s"' % (name, cp.get("base_url", "")),
|
|
469
|
+
"-c", 'model_providers.%s.env_key="%s"' % (name, cp.get("env_key", "ORCH_API_KEY")),
|
|
470
|
+
"-c", 'model_providers.%s.wire_api="%s"' % (name, cp.get("wire_api", "responses"))]
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _model_flag(kind, model):
|
|
474
|
+
if kind in ("codex", "qwen"):
|
|
475
|
+
return ["-m", model]
|
|
476
|
+
return ["--model", model] # claude / opencode / aider
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
_TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
|
|
480
|
+
"unavailable", "overloaded", "rate limit", "timeout", "timed out",
|
|
481
|
+
# 2026-09-15 连载验收实测:网关故障形态远不止 HTTP 5xx——
|
|
482
|
+
# Z.ai 报 "400 [1211] Unknown Model"(模型临时下架)、qwen 连本地
|
|
483
|
+
# 端点 ECONNREFUSED、codex initialize 空响应,这些都被旧表判成
|
|
484
|
+
# 「非瞬态不降级」,导致跨厂商链上健康的后继模型从未被尝试。
|
|
485
|
+
"unknown model", "1211", "connection error", "econnrefused",
|
|
486
|
+
"connection aborted", "initialize", "reset by peer",
|
|
487
|
+
"channel is closed", "no route to host")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _transient_error(err):
|
|
491
|
+
err = (err or "").lower()
|
|
492
|
+
return any(k in err for k in _TRANSIENT)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# 欠费/配额耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
|
|
496
|
+
# 与 modelhub._QUOTA_HINTS 同源,这里独立一份避免 core 模块间循环依赖。
|
|
497
|
+
_QUOTA = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
|
|
498
|
+
"payment required", "402", "欠费", "余额", "额度", "exceeded")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _quota_error(err):
|
|
502
|
+
err = (err or "").lower()
|
|
503
|
+
return any(k in err for k in _QUOTA)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _report_key(att, out):
|
|
507
|
+
"""把这次尝试的结果回写到 KEY 账本:欠费/失败 → 冷却,成功 → 清错误。
|
|
508
|
+
|
|
509
|
+
回写失败绝不能影响主流程(账本是旁路),所以整体吞异常。
|
|
510
|
+
"""
|
|
511
|
+
pid, kid = att.get("provider_id") or "", att.get("key_id") or ""
|
|
512
|
+
if not (pid and kid):
|
|
513
|
+
return
|
|
514
|
+
try:
|
|
515
|
+
from . import modelhub
|
|
516
|
+
if out.get("ok"):
|
|
517
|
+
modelhub.note_key_ok(pid, kid)
|
|
518
|
+
elif out.get("error"):
|
|
519
|
+
modelhub.note_key_error(pid, kid, out["error"])
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _classify_failure(res, *, parsed=None, kind="", attempt_done=False, empty_output=False):
|
|
525
|
+
"""根据 run_process 结果 + 解析结果,返回 ErrorCode 字符串。
|
|
526
|
+
|
|
527
|
+
设计稿:docs/migration/01-defense-patterns.md §5D + §2D。
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
res: run_process 返回的 dict(含 ok/cancelled/timed_out/exit_code/stdout/stderr)
|
|
531
|
+
parsed: claude 解析后的 dict(或 None);codex 由 caller 处理
|
|
532
|
+
kind: "claude" | "codex" | "generic" | ...
|
|
533
|
+
attempt_done: claude 空响应是否已重试一次(True=已是第二次)
|
|
534
|
+
empty_output: 调用方已确认 stdout 为空(用于 generic/codex 的空响应)
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
ErrorCode 枚举值字符串;成功返回 ""。
|
|
538
|
+
"""
|
|
539
|
+
if res.get("cancelled"):
|
|
540
|
+
return ErrorCode.CANCELLED
|
|
541
|
+
if res.get("timed_out"):
|
|
542
|
+
return ErrorCode.TIMEOUT
|
|
543
|
+
# claude 解析失败(进程 ok 但 JSON 不可解析)
|
|
544
|
+
if kind == "claude" and parsed is None:
|
|
545
|
+
return ErrorCode.PARSE_FAIL
|
|
546
|
+
# claude 明确 is_error
|
|
547
|
+
if parsed and parsed.get("is_error"):
|
|
548
|
+
return ErrorCode.VENDOR_REFUSAL
|
|
549
|
+
# claude 两次都空
|
|
550
|
+
if kind == "claude" and attempt_done and not (parsed or {}).get("text", "").strip():
|
|
551
|
+
return ErrorCode.EMPTY
|
|
552
|
+
# generic/codex 空输出(caller 已确认)
|
|
553
|
+
if empty_output and not parsed:
|
|
554
|
+
return ErrorCode.EMPTY
|
|
555
|
+
# 退出码非 0(vendor 内部崩溃)
|
|
556
|
+
ec = res.get("exit_code")
|
|
557
|
+
if ec is not None and ec != 0:
|
|
558
|
+
return ErrorCode.VENDOR_ERROR
|
|
559
|
+
return ""
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _resolve_attempts(agent):
|
|
563
|
+
"""把 agent 的模型配置展开为逐次尝试列表。
|
|
564
|
+
|
|
565
|
+
优先用跨厂商链 call_chain(每条自带 env / codex_provider,来自不同供应商);
|
|
566
|
+
无链时退回 model + model_fallbacks(同一 CLI 进程内换 -m,沿用 agent 级注入)。
|
|
567
|
+
|
|
568
|
+
链条目可能带 key_id/provider_id(同一厂商多把 KEY 展开成的多条)——失败时
|
|
569
|
+
据此把「哪把 KEY 不行」回写冷却,后续解析自动切备用。
|
|
570
|
+
"""
|
|
571
|
+
chain = agent.get("call_chain") or []
|
|
572
|
+
if chain:
|
|
573
|
+
return [{"model": (e.get("model") or "").strip() or None,
|
|
574
|
+
"env": dict(e.get("env") or {}),
|
|
575
|
+
"from_chain": True,
|
|
576
|
+
"own_cp": "codex_provider" in e,
|
|
577
|
+
"codex_provider": e.get("codex_provider"),
|
|
578
|
+
"provider_id": e.get("provider_id") or "",
|
|
579
|
+
"key_id": e.get("key_id") or ""} for e in chain]
|
|
580
|
+
base_model = agent.get("model")
|
|
581
|
+
fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
|
|
582
|
+
models_to_try = ([base_model] if base_model else []) + fb
|
|
583
|
+
return [{"model": m or None, "env": {}, "from_chain": False, "own_cp": False,
|
|
584
|
+
"codex_provider": None, "provider_id": "", "key_id": ""}
|
|
585
|
+
for m in (models_to_try or [None])[:3]]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
|
|
589
|
+
"""构建一次 CLI 调用的 (argv, stdin_text, prompt)。model 可为 None=CLI 默认。
|
|
590
|
+
images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。"""
|
|
591
|
+
env = {}
|
|
592
|
+
argv = None
|
|
593
|
+
stdin_text = None
|
|
594
|
+
imgs = [str(p) for p in (images or []) if p]
|
|
595
|
+
if kind == "codex":
|
|
596
|
+
cp = agent.get("codex_provider")
|
|
597
|
+
if sid:
|
|
598
|
+
# resume 子命令不支持 -s 也不支持 --full-auto(0.154 实测:
|
|
599
|
+
# "unexpected argument '--full-auto'")——读/写模式都用 -c sandbox_mode
|
|
600
|
+
argv = resolve_command(agent["command"]) + [
|
|
601
|
+
"exec", "resume", sid, "-",
|
|
602
|
+
"--skip-git-repo-check", "--json"]
|
|
603
|
+
if model:
|
|
604
|
+
argv += ["-m", model]
|
|
605
|
+
argv += ["-c", 'sandbox_mode="%s"' %
|
|
606
|
+
("read-only" if readonly else "workspace-write")]
|
|
607
|
+
if cp:
|
|
608
|
+
argv += _codex_provider_args(cp)
|
|
609
|
+
else:
|
|
610
|
+
argv = resolve_command(agent["command"]) + [
|
|
611
|
+
"exec", "--skip-git-repo-check", "--json",
|
|
612
|
+
"-s", "read-only" if readonly else "workspace-write"]
|
|
613
|
+
if model:
|
|
614
|
+
argv += ["-m", model]
|
|
615
|
+
if cp:
|
|
616
|
+
argv += _codex_provider_args(cp)
|
|
617
|
+
# codex exec 与 exec resume 都支持 -i:图片直接附到 prompt(exec resume 的
|
|
618
|
+
# -i 附在恢复后发送的首条消息上,即本次 stdin prompt)
|
|
619
|
+
for p in imgs:
|
|
620
|
+
argv += ["-i", p]
|
|
621
|
+
stdin_text = prompt
|
|
622
|
+
elif kind == "claude":
|
|
623
|
+
argv = resolve_command(agent["command"]) + ["-p", "--output-format", "json"]
|
|
624
|
+
if sid:
|
|
625
|
+
argv += ["--resume", sid]
|
|
626
|
+
if model:
|
|
627
|
+
argv += ["--model", model]
|
|
628
|
+
if readonly:
|
|
629
|
+
# 实测本机自定义网关在 -p 模式下工具续接会丢最终结果(用工具必空)。
|
|
630
|
+
# 评审/规划所需的上下文已内嵌在提示词中,显式禁用工具最稳。
|
|
631
|
+
prompt = "(请勿使用任何工具,直接依据下方内容回答。)\n\n" + prompt
|
|
632
|
+
else:
|
|
633
|
+
argv += ["--permission-mode", "acceptEdits"]
|
|
634
|
+
stdin_text = prompt
|
|
635
|
+
elif kind == "opencode":
|
|
636
|
+
argv = resolve_command(agent["command"]) + ["run"]
|
|
637
|
+
if sid:
|
|
638
|
+
argv += ["-s", sid] # 无头续会话:-s 指定会话 id(-c 只能接最近一次)
|
|
639
|
+
if model:
|
|
640
|
+
argv += ["--model", model]
|
|
641
|
+
stdin_text = prompt # 无位置参数且 stdin 有内容时读 stdin
|
|
642
|
+
elif kind == "qwen": # gemini-cli 系:无参数且 stdin 有内容时读 stdin
|
|
643
|
+
argv = resolve_command(agent["command"])
|
|
644
|
+
if sid:
|
|
645
|
+
argv += ["-r", sid] # 恢复指定会话(~/.qwen/projects/*/chats/<sid>.jsonl)
|
|
646
|
+
if model:
|
|
647
|
+
argv += ["-m", model]
|
|
648
|
+
stdin_text = prompt
|
|
649
|
+
elif kind == "aider":
|
|
650
|
+
argv = resolve_command(agent["command"]) + [
|
|
651
|
+
"--yes-always", "--no-auto-commits", "--no-check-update", "--message", prompt]
|
|
652
|
+
if model:
|
|
653
|
+
argv += ["--model", model]
|
|
654
|
+
else: # generic:模板把 {prompt}/{session} 嵌进参数(注意 cmd 行长度限制)
|
|
655
|
+
tmpl = agent.get("argv_template") or ["-p", "{prompt}"]
|
|
656
|
+
if sid and agent.get("resume_argv_template"):
|
|
657
|
+
tmpl = agent["resume_argv_template"]
|
|
658
|
+
argv = resolve_command(agent["command"]) + [
|
|
659
|
+
str(a).replace("{prompt}", prompt).replace("{session}", sid) for a in tmpl]
|
|
660
|
+
if "{prompt}" not in tmpl:
|
|
661
|
+
stdin_text = prompt # 恢复模板不带 {prompt}:提示词走 stdin(mimo 实测支持)
|
|
662
|
+
return argv, stdin_text, prompt
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _check_approval(agent):
|
|
666
|
+
"""5G:catalog entry sensitive + policy=NEVER → 拒绝(无人值守不静默降级)。
|
|
667
|
+
|
|
668
|
+
Policy 来源:环境变量 TUTTI_APPROVAL_POLICY(默认 "never")。
|
|
669
|
+
sensitive 字段从 catalog orch.sensitive 读取。
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
(True, "") 表示放行;
|
|
673
|
+
(False, reason) 表示拒绝(error_code=ENV_BLOCK)。
|
|
674
|
+
"""
|
|
675
|
+
policy = os.environ.get("TUTTI_APPROVAL_POLICY", "never").strip().lower()
|
|
676
|
+
sensitive = bool((agent.get("orch") or {}).get("sensitive"))
|
|
677
|
+
if not sensitive:
|
|
678
|
+
return True, ""
|
|
679
|
+
if policy == "never":
|
|
680
|
+
return False, "sensitive step blocked by policy=never (set TUTTI_APPROVAL_POLICY=ask to require explicit approval)"
|
|
681
|
+
# policy=ask 留 Phase 5 做完整 UI 弹窗流程;当前等价 never
|
|
682
|
+
return False, "sensitive step requires policy=ask UI flow (Phase 5) — currently blocking"
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
686
|
+
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
|
|
687
|
+
images=None):
|
|
688
|
+
"""执行一次智能体调用,返回统一结构
|
|
689
|
+
{ok, text, json, cost_usd, tokens, error, error_code, raw}。
|
|
690
|
+
agent 来自 registry.effective_agents();resume 为已有会话 id,仅真实智能体生效
|
|
691
|
+
(codex: exec resume;claude: --resume;opencode/mimo: run -s;qwen: -r;
|
|
692
|
+
generic: catalog orch.resume_argv_template)。
|
|
693
|
+
images:任务图片附件的绝对路径,仅 codex 原生支持(-i);其余智能体靠
|
|
694
|
+
提示词里的 _attachments/ 路径 + 自身读文件能力获取,无读图工具时静默忽略。
|
|
695
|
+
|
|
696
|
+
模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
|
|
697
|
+
主模型 + 降级备选;瞬态错误才换下一条,取消/超时/解析失败不降级。
|
|
698
|
+
|
|
699
|
+
5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout。
|
|
700
|
+
"""
|
|
701
|
+
# 5E:catalog orch.timeout_ms 优先
|
|
702
|
+
orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
|
|
703
|
+
if orch_timeout_ms:
|
|
704
|
+
timeout = float(orch_timeout_ms) / 1000.0
|
|
705
|
+
kind = agent.get("kind", "generic")
|
|
706
|
+
# 5G:approval NEVER 一线(无人值守不静默降级)
|
|
707
|
+
ok, reason = _check_approval(agent)
|
|
708
|
+
if not ok:
|
|
709
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
|
|
710
|
+
"usage": None, "error": reason,
|
|
711
|
+
"error_code": ErrorCode.ENV_BLOCK,
|
|
712
|
+
"raw": None, "kind": kind, "model": None}
|
|
713
|
+
base_env = dict(agent.get("env") or {})
|
|
714
|
+
if kind == "claude":
|
|
715
|
+
bash = find_git_bash()
|
|
716
|
+
if bash:
|
|
717
|
+
base_env.setdefault("CLAUDE_CODE_GIT_BASH_PATH", bash)
|
|
718
|
+
base_env.setdefault("CLAUDE_CODE_MAX_OUTPUT_TOKENS", "16000")
|
|
719
|
+
sid = (resume or "").strip() if agent.get("mode") == "real" else ""
|
|
720
|
+
if sid and kind == "generic" and not agent.get("resume_argv_template"):
|
|
721
|
+
return {"ok": False, "text": "", "json": None, "cost_usd": 0.0, "tokens": 0,
|
|
722
|
+
"usage": None,
|
|
723
|
+
"error": "该 CLI 未配置会话恢复(catalog orch.resume_argv_template),"
|
|
724
|
+
"无法在已有会话上继续",
|
|
725
|
+
"error_code": ErrorCode.VENDOR_ERROR,
|
|
726
|
+
"sid": "", "raw": None, "kind": kind, "model": None}
|
|
727
|
+
|
|
728
|
+
attempts = _resolve_attempts(agent)
|
|
729
|
+
out = None
|
|
730
|
+
for ai, att in enumerate(attempts):
|
|
731
|
+
env = dict(base_env)
|
|
732
|
+
env.update(att["env"])
|
|
733
|
+
if kind == "codex":
|
|
734
|
+
# 网关自定义模型名(如 [opencode]xxx)是合法 API 模型名不能改,但方括号
|
|
735
|
+
# 是非法 OTel tag 值 → codex_otel 每个 SSE 事件刷 2 条 WARN 淹没真输出。
|
|
736
|
+
# 只静音遥测模块(codex 不认 RUST_LOG 也无害),其余 WARN 全保留。
|
|
737
|
+
env.setdefault("RUST_LOG", "codex_otel=off")
|
|
738
|
+
eff_agent = dict(agent)
|
|
739
|
+
eff_agent["env"] = env
|
|
740
|
+
if att["own_cp"]:
|
|
741
|
+
eff_agent["codex_provider"] = att["codex_provider"]
|
|
742
|
+
elif att["from_chain"] and "codex_provider" in eff_agent:
|
|
743
|
+
# 链内条目未注入供应商时不能沿用上一条(可能是另一家厂商)的 -c 覆盖
|
|
744
|
+
del eff_agent["codex_provider"]
|
|
745
|
+
for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
|
|
746
|
+
argv, stdin_text, prompt_eff = _build_call(eff_agent, kind, sid, readonly,
|
|
747
|
+
att["model"], prompt,
|
|
748
|
+
images=images if kind == "codex" else None)
|
|
749
|
+
res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
|
|
750
|
+
timeout=timeout, cancel_event=cancel_event, log_path=log_path)
|
|
751
|
+
out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
|
|
752
|
+
"tokens": 0, "usage": None, "error": "", "error_code": "",
|
|
753
|
+
"sid": "", "raw": res, "kind": kind, "model": att["model"]}
|
|
754
|
+
if not res["ok"]:
|
|
755
|
+
# stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
|
|
756
|
+
# stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
|
|
757
|
+
# 里——只取其一会让 _quota_error/_transient_error 判空。
|
|
758
|
+
tail = ((res["stderr"] or "") + "\n" + (res["stdout"] or "")).strip()[-600:]
|
|
759
|
+
out["error"] = (("超时" if res["timed_out"] else "取消" if res["cancelled"]
|
|
760
|
+
else "退出码 %s" % res["exit_code"])
|
|
761
|
+
+ (";stderr/stdout: " + tail if tail else ""))
|
|
762
|
+
if kind == "codex":
|
|
763
|
+
fm = _codex_fail_msg(res["stdout"])
|
|
764
|
+
if fm:
|
|
765
|
+
# 事件流终态错误比原始 JSONL 尾部可读,也是链降级判定依据
|
|
766
|
+
out["error"] = "codex: %s(退出码 %s)" % (fm, res["exit_code"])
|
|
767
|
+
# 5D+2D:错误码归一
|
|
768
|
+
if kind == "claude":
|
|
769
|
+
parsed_err = _parse_claude_json(res["stdout"] or "")
|
|
770
|
+
if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
|
|
771
|
+
out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
|
|
772
|
+
out["error_code"] = _classify_failure(res, kind=kind)
|
|
773
|
+
break
|
|
774
|
+
if kind == "codex":
|
|
775
|
+
out["text"], out["usage"], out_sid = _parse_codex_jsonl(res["stdout"])
|
|
776
|
+
out["sid"] = out_sid # §07 T1.1:会话 id 供 revise/fix 复用
|
|
777
|
+
out["tokens"] = out["usage"]["total"]
|
|
778
|
+
# 退出码 0 不代表成功:配额耗尽时 turn.failed 收尾、进程仍正常退出,
|
|
779
|
+
# 不判失败的话编排者会把错误信息当成果往下传
|
|
780
|
+
fm = _codex_fail_msg(res["stdout"])
|
|
781
|
+
if fm:
|
|
782
|
+
out["ok"] = False
|
|
783
|
+
out["error"] = "codex: %s" % fm
|
|
784
|
+
out["error_code"] = ErrorCode.VENDOR_ERROR
|
|
785
|
+
elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
|
|
786
|
+
out["text"] = res["stdout"][-2000:]
|
|
787
|
+
if not out["text"] and out["ok"]:
|
|
788
|
+
out["error_code"] = _classify_failure(res, kind="codex", empty_output=True)
|
|
789
|
+
elif kind == "claude":
|
|
790
|
+
parsed = _parse_claude_json(res["stdout"])
|
|
791
|
+
if parsed is None:
|
|
792
|
+
out["ok"] = False
|
|
793
|
+
out["error"] = "claude 输出无法解析为 JSON;stdout 尾部: " + res["stdout"][-500:]
|
|
794
|
+
out["error_code"] = _classify_failure(res, kind="claude", parsed=None)
|
|
795
|
+
break
|
|
796
|
+
out["text"] = parsed["text"]
|
|
797
|
+
out["cost_usd"] = parsed["cost_usd"]
|
|
798
|
+
out["tokens"] = parsed["tokens"]
|
|
799
|
+
out["usage"] = parsed["usage"]
|
|
800
|
+
out["sid"] = parsed.get("sid") or "" # §07 T1.1
|
|
801
|
+
if parsed["is_error"]:
|
|
802
|
+
out["ok"] = False
|
|
803
|
+
out["error"] = "claude 返回 is_error: " + parsed["text"][:500]
|
|
804
|
+
out["error_code"] = _classify_failure(res, kind="claude", parsed=parsed)
|
|
805
|
+
break
|
|
806
|
+
if not out["text"] and attempt == 0:
|
|
807
|
+
continue # 空响应,同模型重试
|
|
808
|
+
if not out["text"] and attempt == 1:
|
|
809
|
+
out["error_code"] = _classify_failure(
|
|
810
|
+
res, kind="claude", parsed=parsed, attempt_done=True)
|
|
811
|
+
else:
|
|
812
|
+
out["text"] = res["stdout"].strip()
|
|
813
|
+
if not out["text"]:
|
|
814
|
+
out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
|
|
815
|
+
break
|
|
816
|
+
_report_key(att, out)
|
|
817
|
+
if out["ok"] or ai == len(attempts) - 1:
|
|
818
|
+
return out
|
|
819
|
+
# 瞬态网络错误 → 换下一条;欠费/配额耗尽同样换(可能是同厂商的备用 KEY,
|
|
820
|
+
# 也可能是另一家厂商)——账单断了死磕同一把 KEY 没有任何意义。
|
|
821
|
+
if not (_transient_error(out.get("error")) or _quota_error(out.get("error"))):
|
|
822
|
+
return out # 非瞬态(取消/超时/解析失败)不降级
|
|
823
|
+
return out
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def extract_json(text):
|
|
827
|
+
"""从模型回复中提取 JSON:直接解析 → ```json 围栏 → 平衡花括号扫描。"""
|
|
828
|
+
if not text:
|
|
829
|
+
return None
|
|
830
|
+
text = text.strip()
|
|
831
|
+
try:
|
|
832
|
+
return json.loads(text)
|
|
833
|
+
except Exception:
|
|
834
|
+
pass
|
|
835
|
+
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
|
|
836
|
+
if m:
|
|
837
|
+
try:
|
|
838
|
+
return json.loads(m.group(1))
|
|
839
|
+
except Exception:
|
|
840
|
+
pass
|
|
841
|
+
start = text.find("{")
|
|
842
|
+
while start != -1:
|
|
843
|
+
depth = 0
|
|
844
|
+
for i in range(start, len(text)):
|
|
845
|
+
c = text[i]
|
|
846
|
+
if c == "{":
|
|
847
|
+
depth += 1
|
|
848
|
+
elif c == "}":
|
|
849
|
+
depth -= 1
|
|
850
|
+
if depth == 0:
|
|
851
|
+
try:
|
|
852
|
+
return json.loads(text[start:i + 1])
|
|
853
|
+
except Exception:
|
|
854
|
+
break
|
|
855
|
+
start = text.find("{", start + 1)
|
|
856
|
+
return None
|