codebee 0.1.17 → 0.1.19

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.
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, catalog, history, jobs, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, usage
22
+ from . import aiflavor, catalog, history, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, usage
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -310,7 +310,8 @@ def _wait_gate(run_id, ev):
310
310
  return
311
311
  if ev is not None and ev.is_set():
312
312
  return
313
- time.sleep(1.0)
313
+ # ev.wait 睡等:取消置位即刻醒来,不必耗满 1s 轮询间隔
314
+ (ev.wait(1.0) if ev is not None else time.sleep(1.0))
314
315
 
315
316
 
316
317
  def _binding_dead_msg(agent):
@@ -632,6 +633,7 @@ __GOAL__
632
633
 
633
634
  ## 本步指令
634
635
  __SUBTASK__
636
+ __FILES__
635
637
 
636
638
  ## 背景与上下文
637
639
  __CONTEXT__
@@ -693,6 +695,16 @@ def _verify_hint(task):
693
695
  return ""
694
696
 
695
697
 
698
+ def _files_hint(sub):
699
+ """计划锚定文件清单(OpenSpec explore 借鉴):计划里带了 files 就明示改动面,
700
+ 让实现者知道该动哪些文件、不必全盘摸索。无 files 时返回空串(旧计划兼容)。"""
701
+ files = (sub or {}).get("files")
702
+ if not isinstance(files, list) or not files:
703
+ return ""
704
+ return "\n- 本步涉及文件(计划已锚定,先读再改):" + "、".join(
705
+ "`%s`" % str(f) for f in files[:10])
706
+
707
+
696
708
  def _run_verify(run_id, task, workdir, ev):
697
709
  """确定性验证。返回 (verify_pass, ran)。"""
698
710
  if not task.get("verify_command"):
@@ -803,6 +815,7 @@ def _code_bestof(run, task, impl, difficulty, ev):
803
815
  prompt = (_RUN_CONSTITUTION + CODE_IMPL_PROMPT
804
816
  .replace("__GOAL__", task["goal"])
805
817
  .replace("__SUBTASK__", sub["detail"] if sub["detail"] else sub["title"])
818
+ .replace("__FILES__", _files_hint(sub))
806
819
  .replace("__CONTEXT__", task.get("context") or "(无)")
807
820
  .replace("__VERIFY_HINT__", _verify_hint(task)))
808
821
  res = _run_step(run_id, "race%d-impl" % k, agt_b, prompt, wt_path,
@@ -953,6 +966,7 @@ def _run_code(run, task, agents, ev, stats, mode):
953
966
  .replace("__GOAL__", task["goal"])
954
967
  .replace("__SUBTASK__",
955
968
  sub["detail"] if sub["detail"] else sub["title"])
969
+ .replace("__FILES__", _files_hint(sub))
956
970
  .replace("__CONTEXT__", task.get("context") or "(无)")
957
971
  .replace("__VERIFY_HINT__", _verify_hint(task))) + prog
958
972
  role = "implement" if len(subtasks) == 1 else "implement-%d/%d" % (i + 1, len(subtasks))
@@ -1421,7 +1435,8 @@ RESEARCH_APPENDIX = """
1421
1435
  单源信息要标注「仅单一来源」。
1422
1436
  - 引用来源在文中用行内链接或脚注标明(域名即可,不编造 URL)。
1423
1437
  - 区分「事实」与「观点」:数据/时间/版本号给来源,预测/评价标明是分析。
1424
- - 结构建议:结论先行 → 分层论证 → 风险与局限(说明哪些结论证据不足)。"""
1438
+ - 结构硬性要求:报告第一段必须是「**核心结论**」三行以内的要点摘要(结论先行),
1439
+ 之后才展开分层论证 → 风险与局限(说明哪些结论证据不足)。"""
1425
1440
 
1426
1441
  NOVEL_REVISE_PROMPT = """你是一名专业作者。请根据下方汇总评审意见修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1427
1442
 
@@ -1647,6 +1662,10 @@ def _critique_json(res, dims):
1647
1662
  if isinstance(gj, dict) and isinstance(gj.get("scores"), dict) and gj.get("scores"):
1648
1663
  return gj
1649
1664
  prose = runner.scores_from_prose(text, dims)
1665
+ if not prose:
1666
+ # 第四道网(BAML 借鉴):维度名没命中时按「X:N 分」模式泛化抓取——
1667
+ # 自定义 rubric 改了维度措辞而模型用了自己的说法时仍能救回
1668
+ prose = runner.extract_scores_from_text(text)
1650
1669
  if prose:
1651
1670
  return {"scores": prose, "issues": [], "summary": text[:400]}
1652
1671
  return {"scores": {}, "issues": [],
@@ -1777,6 +1796,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1777
1796
  tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % sk, 1)
1778
1797
  if bible:
1779
1798
  tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % bible, 1)
1799
+ kb = knowledge.block_for(task)
1800
+ if kb:
1801
+ tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % kb, 1)
1780
1802
  return tpl.replace("__DIMKEYS__", dimkey).replace(
1781
1803
  "__MANUSCRIPT__", text or "(稿件为空!)")
1782
1804
 
@@ -1974,6 +1996,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1974
1996
  sk_block, _ = skills.block_for(task, stable_order=True)
1975
1997
  if bible:
1976
1998
  sk_block = (sk_block + "\n\n" + bible) if sk_block else bible
1999
+ kb_block = knowledge.block_for(task)
2000
+ if kb_block:
2001
+ sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
1977
2002
  scope = ("本章 = 大纲第 %d 章" % i) if start == 1 else (
1978
2003
  "本批为第 %d–%d 章,下列按全书章号列出各章要点" % (start, end))
1979
2004
 
@@ -2010,9 +2035,12 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2010
2035
  use_prompt = prompt
2011
2036
  for draft_attempt in range(3):
2012
2037
  if draft_attempt:
2013
- if ev is not None and ev.is_set():
2014
- break
2015
- time.sleep(30 * draft_attempt) # 30s / 60s 退避
2038
+ # 30s / 60s 退避;ev.wait 睡等可被取消即刻唤醒
2039
+ if ev is not None:
2040
+ if ev.wait(30 * draft_attempt):
2041
+ break
2042
+ else:
2043
+ time.sleep(30 * draft_attempt)
2016
2044
  if draft_attempt and len(prompt) > 12000 and sk_block and sk_block in prompt:
2017
2045
  # 长提示词在容量受限通道(讯飞托管 35B 等)上会挂起/秒拒
2018
2046
  # ——分层降级:经验库→4K、模块库按模块边界、圣经按二级标题
@@ -2097,9 +2125,12 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2097
2125
  for race_round in range(2):
2098
2126
  # 全变体失败(网关突发限流)→ 60s 退避重赛一轮,别一章判死
2099
2127
  if race_round:
2100
- if ev is not None and ev.is_set():
2101
- break
2102
- time.sleep(60)
2128
+ # ev.wait 睡等可被取消即刻唤醒
2129
+ if ev is not None:
2130
+ if ev.wait(60):
2131
+ break
2132
+ else:
2133
+ time.sleep(60)
2103
2134
  for kk in range(n_variants):
2104
2135
  # 清上一轮残稿:防陈旧半成品被本轮评分误认成新成品
2105
2136
  try:
@@ -3002,11 +3033,28 @@ def _write_task_evidence(run_id, task, workdir, summary_lines):
3002
3033
  for ln in summary_lines:
3003
3034
  f.write("- %s\n" % str(ln)[:300])
3004
3035
  f.write("\n")
3036
+ _archive_stamp(run_id, task, workdir, summary_lines)
3005
3037
  return path
3006
3038
  except Exception:
3007
3039
  return ""
3008
3040
 
3009
3041
 
3042
+ def _archive_stamp(run_id, task, workdir, summary_lines):
3043
+ """归档戳(借鉴 OpenSpec archive):evidence 落盘后向 spec.md 尾部追加
3044
+ 「已完成」快照行——spec 从「意图」升格为「意图+交付记录」的活档案。
3045
+ 失败静默。"""
3046
+ try:
3047
+ spec = os.path.join(workdir, ".codebee", "spec.md")
3048
+ if not os.path.isfile(spec):
3049
+ return
3050
+ with open(spec, "a", encoding="utf-8") as f:
3051
+ f.write("\n---\n**✅ 已交付** · %s · run %s\n%s\n" % (
3052
+ _now(), run_id,
3053
+ "\n".join("- %s" % str(ln)[:160] for ln in summary_lines[:5])))
3054
+ except Exception:
3055
+ return
3056
+
3057
+
3010
3058
  def _evidence_lines_from_run(run_id, task):
3011
3059
  """从 run 步骤与 verdict 提取证据行(确定性事实,不抄模型输出)。"""
3012
3060
  run = store.get_run(run_id) or {}
@@ -3190,3 +3238,8 @@ def execute_run(run_id):
3190
3238
  skills.learn_async(run_id)
3191
3239
  except Exception:
3192
3240
  pass
3241
+ # 知识库闭环:从产出材料提炼可复用知识条目(草稿态,人工转正后参与注入)
3242
+ try:
3243
+ knowledge.learn_async(run_id)
3244
+ except Exception:
3245
+ pass
@@ -12,7 +12,7 @@ import os
12
12
  import re
13
13
  import time
14
14
 
15
- from . import modelhub, runner, skills, usage
15
+ from . import knowledge, modelhub, runner, skills, usage
16
16
 
17
17
  MAX_SUBTASKS = 4
18
18
  DEFAULT_OUTLINE_TIMEOUT = 900 # 8 章大纲 + 经验包注入是重生成任务,300s 实测不够
@@ -116,10 +116,16 @@ def _log_usage(source, role, task, res, agent=None, tool="", model="", provider=
116
116
 
117
117
  CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
118
118
  并判定任务难度。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
119
- {"difficulty": "easy 或 hard", "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令"}]}
119
+ {"difficulty": "easy 或 hard", "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令", "files": ["涉及的文件路径"]}]}
120
120
  难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
121
121
  子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
122
122
 
123
+ ## 先探索再计划(重要,借鉴 OpenSpec explore)
124
+ 拆解之前先用你的读文件/搜索工具**实际查看工作目录**,找到目标相关的真实文件与函数,再据此拆解。
125
+ detail 必须点名真实存在的文件路径;files 列出该子任务会改动的文件(新建的写目标路径)。
126
+ **没探索过就不要凭想象编路径**——计划的可执行性取决于它对真实代码库的贴合度。
127
+ 若工作目录为空(全新项目),files 写计划新建的文件路径。
128
+
123
129
  ## 开发目标
124
130
  __GOAL__
125
131
 
@@ -274,6 +280,9 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
274
280
  wpc = int(serial.get("words_per_chapter") or 2500)
275
281
  start = int(serial.get("start_chapter") or 1)
276
282
  sk_block, _ = skills.block_for(task)
283
+ kb_block = knowledge.block_for(task)
284
+ if kb_block:
285
+ sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
277
286
  prev_title = ""
278
287
  if start > 1:
279
288
  prev_lines, done, prev_title, prev_tail = _prev_serial_story(task)
@@ -367,7 +376,10 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
367
376
 
368
377
 
369
378
  def _norm_subtasks(data):
370
- """规范化 LLM 计划输出;不合规返回 None。"""
379
+ """规范化 LLM 计划输出;不合规返回 None。
380
+
381
+ files 字段(OpenSpec explore 借鉴):计划锚定的真实文件清单,透传给执行步
382
+ 让实现者知道改动面(缺失/非法时留空,向后兼容旧计划)。"""
371
383
  if not isinstance(data, dict):
372
384
  return None
373
385
  subs = data.get("subtasks")
@@ -381,7 +393,13 @@ def _norm_subtasks(data):
381
393
  detail = str(s.get("detail") or "").strip()
382
394
  if not title:
383
395
  continue
384
- steps.append({"title": title[:60], "detail": detail[:1500]})
396
+ step = {"title": title[:60], "detail": detail[:1500]}
397
+ files = s.get("files")
398
+ if isinstance(files, list):
399
+ clean = [str(f).strip()[:200] for f in files if str(f).strip()][:10]
400
+ if clean:
401
+ step["files"] = clean
402
+ steps.append(step)
385
403
  return steps or None
386
404
 
387
405
 
@@ -86,16 +86,56 @@ def view():
86
86
 
87
87
 
88
88
  def recover_orphans():
89
- """启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。"""
89
+ """启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。
90
+
91
+ 升级自愈:旧版「等扫码窗口一关就冤判超时」留下的 error(error 文案带
92
+ 「等待登录超时」)排队后台复核——profile 登录态还在的直接翻 connected,
93
+ 别让升级完还挂着旧冤案;attach 不到活实例就维持原样(用户点重连时
94
+ 新终审逻辑自会兜住)。"""
90
95
  _load()
91
96
  n = 0
97
+ stale = []
92
98
  for plat in PLATFORMS:
93
- if _st(plat).get("status") in ("waiting_login", "busy"):
99
+ s = _st(plat)
100
+ if s.get("status") in ("waiting_login", "busy"):
94
101
  _set(plat, status="error", error="上次操作随服务重启中断,请重试")
95
102
  n += 1
103
+ elif s.get("status") == "error" and "等待登录超时" in (s.get("error") or ""):
104
+ stale.append(plat)
105
+ if stale:
106
+ threading.Thread(target=_recheck_stale_errors, args=(stale,),
107
+ daemon=True, name="pub-stale-recheck").start()
96
108
  return n
97
109
 
98
110
 
111
+ def _recheck_stale_errors(plats):
112
+ """旧版假超时的后台复核:只 attach 还活着的浏览器实例(绝不 launch——
113
+ 升级重启时静默弹窗口吓人),登录态复核过了翻 connected;否则不动。"""
114
+ for plat in plats:
115
+ if _st(plat).get("status") != "error":
116
+ continue # 用户已先行操作(重连/断开),别覆盖
117
+ port = _st(plat).get("port") or 0
118
+ if not port:
119
+ continue
120
+ try:
121
+ b = Browser.attach(int(port))
122
+ except Exception:
123
+ continue # 实例已死:不 launch,维持原错误
124
+ with LOCK:
125
+ _browsers[plat] = b
126
+ page = b.first_page(create=False)
127
+ if page is None:
128
+ continue
129
+ try:
130
+ ok, _u = _check_login(plat, page)
131
+ except Exception:
132
+ continue
133
+ if ok and _st(plat).get("status") == "error":
134
+ _set(plat, status="connected", error="",
135
+ last_login=time.strftime("%m-%d %H:%M"))
136
+ ledger.record(plat, "connect", ok=True)
137
+
138
+
99
139
  # ---------------------------------------------------------------- 浏览器会话
100
140
  def _profiles_base():
101
141
  """profile 存放根:用户主目录 ~/.codebee/publish_profiles(仓库外)。
@@ -245,13 +285,31 @@ def connect(plat):
245
285
  except (BrowserError, Exception):
246
286
  pass # 页面被用户关掉等:继续等到超时
247
287
  time.sleep(5)
248
- _set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
288
+ _finalize_login_wait(plat)
249
289
 
250
290
  threading.Thread(target=wait_login, daemon=True,
251
291
  name="pub-login-%s" % plat).start()
252
292
  return True, ""
253
293
 
254
294
 
295
+ def _finalize_login_wait(plat):
296
+ """等扫码超时后的终审:用户可能已在本窗口登录后把窗口关了(profile
297
+ 里登录态还在),attach-or-launch 重拉页面再复核一次,别急着冤判
298
+ 超时——「明明登录着却报超时」是发布卡死感的最大来源。复核也过不了
299
+ 才落超时错误。返回 True 表示复核通过(已判 connected)。"""
300
+ try:
301
+ _b, page = _open_page(plat)
302
+ ok, _u = _check_login(plat, page)
303
+ except Exception:
304
+ ok = False
305
+ if ok:
306
+ _set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
307
+ ledger.record(plat, "connect", ok=True)
308
+ else:
309
+ _set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
310
+ return ok
311
+
312
+
255
313
  def disconnect(plat):
256
314
  b = _browsers.pop(plat, None)
257
315
  if b:
@@ -357,6 +415,25 @@ def create_book_async(task_id, plat, auto_submit=False):
357
415
  return True, ""
358
416
 
359
417
 
418
+ # ---------------------------------------------------------------- 动作:登记已有作品
419
+ def register_book(task_id, plat, title, book_id=""):
420
+ """人工登记平台已有作品:建书流程没走通、或用户纯手工在平台上建的
421
+ 书,补进台账让卡片翻到「已建书·发一章」,避免再点「创建作品」造出
422
+ 重复书。只动本地台账不碰浏览器;发章按书名找书,title 必填,
423
+ book_id 选填(有直达 URL 时填)。已登记会覆盖更新(兼纠错口)。"""
424
+ from .. import store
425
+ if plat not in PLATFORMS:
426
+ return False, "未知平台"
427
+ if not store.get_task(task_id):
428
+ return False, "任务不存在"
429
+ title = (title or "").strip()
430
+ if not title:
431
+ return False, "作品名必填(发章按作品名在平台找书)"
432
+ ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
433
+ "title": title[:120]})
434
+ return True, ""
435
+
436
+
360
437
  def _with_tag_steps(steps, groups, values, mod=None):
361
438
  """标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
362
439
  "tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的
@@ -163,8 +163,26 @@ def _pipe_reader(stream, chunks, log_fh, stamp=None):
163
163
  pass
164
164
 
165
165
 
166
+ def _decode_line(blob):
167
+ """单行解码:UTF-8 → GBK → replace(与 decode_output 同一编码纪律)。"""
168
+ try:
169
+ return blob.decode("utf-8")
170
+ except UnicodeDecodeError:
171
+ pass
172
+ try:
173
+ return blob.decode("gbk")
174
+ except UnicodeDecodeError:
175
+ return blob.decode("utf-8", "replace")
176
+
177
+
166
178
  def decode_output(data):
167
- """子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。"""
179
+ """子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。
180
+
181
+ 整块 UTF-8 与整块 GBK 都失败时逐行兜底——日志文件是混合编码(我方 UTF-8
182
+ 审计头 + CLI 自家 GBK 输出),整块回退会把能读的部分一起牺牲:要么头部中文
183
+ 变乱码,要么整篇落进 replace 分支铺成 U+FFFD 墙(2026-09-20 aider 日志实证,
184
+ 详情页卡片显示成一片 ◆)。逐行解码两边都保住(GBK/UTF-8 的多字节序列都不
185
+ 含 0x0A,按行切不会切坏字符)。"""
168
186
  if not data:
169
187
  return ""
170
188
  try:
@@ -174,7 +192,8 @@ def decode_output(data):
174
192
  try:
175
193
  return data.decode("gbk")
176
194
  except UnicodeDecodeError:
177
- return data.decode("utf-8", "replace")
195
+ pass
196
+ return "\n".join(_decode_line(ln) for ln in data.split(b"\n"))
178
197
 
179
198
 
180
199
  def read_text_any_enc(path):
@@ -201,6 +220,64 @@ def tail_decoded(data, tail):
201
220
  return decode_output(chunk[i:])
202
221
 
203
222
 
223
+ # 终端控制序列:CSI(颜色/光标/清行,`\x1b[91m`)、OSC(窗口标题)、其余两字符转义
224
+ _ANSI_RE = re.compile(
225
+ r"\x1b\[[0-9;?]*[ -/]*[@-~]"
226
+ r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?"
227
+ r"|\x1b[@-Z\\-_]"
228
+ )
229
+ # 被切片/前端截断掉 ESC 的「裸序列」残尾(`[91m`、`[0m`):只在行首止血,
230
+ # 正文里合法的 [数字+字母](如 [1m] 引用)不动——后面紧跟 `]` 的不算残尾
231
+ _ANSI_HEAD_RE = re.compile(r"^\[[0-9;?]{1,6}[A-Za-z](?!\])")
232
+ # CLI 拿来做进度条/画框/旋转动画的图元,成串出现时人读不出任何信息
233
+ _NOISE_RUN_RE = re.compile(r"([\u2500-\u259f\u25a0-\u25ff\u2800-\u28ff])\1{7,}")
234
+ # 解不出来的字节(replace 兜底)成串即「乱码墙」——多数等宽字体把 U+FFFD 画成
235
+ # 带问号的菱形,一屏看起来就是 ◆◆◆◆
236
+ _FFFD_RUN_RE = re.compile(r"\ufffd{3,}")
237
+ _CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
238
+
239
+
240
+ def strip_ansi(text):
241
+ """剥掉 ANSI 转义序列(颜色/光标/进度),并清掉行首被截断的裸序列残尾。"""
242
+ if not text:
243
+ return ""
244
+ out = _ANSI_RE.sub("", text)
245
+ # 残尾可能连着几个(`[91m[0mError:`),一次替换只够消掉最前面那个
246
+ while True:
247
+ nxt = "\n".join(_ANSI_HEAD_RE.sub("", ln) for ln in out.split("\n"))
248
+ if nxt == out:
249
+ return out
250
+ out = nxt
251
+
252
+
253
+ def clean_cli_text(text):
254
+ """CLI 原始输出 → 人类可读文本(只用于错误摘要/日志展示,不碰模型正文)。
255
+
256
+ 终端噪声四种,都是实测踩过的:
257
+ 1. ANSI 转义:`\\x1b[91m\\x1b[1mError:` 在浏览器里渲染成 `[91m[1mError:`;
258
+ 2. 裸 `\\r` 覆写:进度条/旋转动画反复回退改写同一行,原样展示会叠成一片;
259
+ `\\r\\n` 是行结束符不是覆写,先归一化再按覆写取「该行最终形态」;
260
+ 3. 画线/进度图元成串(`────…`、`████…`)与 U+FFFD 乱码墙(编码不可解);
261
+ 4. 剩余控制字符。
262
+ """
263
+ if not text:
264
+ return ""
265
+ out = strip_ansi(text).replace("\r\n", "\n")
266
+ if "\r" in out:
267
+ lines = []
268
+ for ln in out.split("\n"):
269
+ if "\r" in ln:
270
+ parts = ln.split("\r")
271
+ ln = next((p for p in reversed(parts) if p.strip()), parts[-1])
272
+ lines.append(ln)
273
+ out = "\n".join(lines)
274
+ out = _CTRL_RE.sub("", out)
275
+ out = _FFFD_RUN_RE.sub(
276
+ lambda m: "…(%d 个字符无法解码:CLI 输出不是 UTF-8/GBK)" % len(m.group(0)), out)
277
+ out = _NOISE_RUN_RE.sub(lambda m: m.group(1) * 3 + "…", out)
278
+ return re.sub(r"\n{4,}", "\n\n\n", out)
279
+
280
+
204
281
  _TS_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}T[\d:.,]+Z?\s*")
205
282
 
206
283
 
@@ -600,7 +677,11 @@ _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
600
677
  # 「非瞬态不降级」,导致跨厂商链上健康的后继模型从未被尝试。
601
678
  "unknown model", "1211", "connection error", "econnrefused",
602
679
  "connection aborted", "initialize", "reset by peer",
603
- "channel is closed", "no route to host")
680
+ "channel is closed", "no route to host",
681
+ # 2026-09-20 连载评审实测:codex 网关断流(stream disconnected)
682
+ # 与 opencode 服务端 500(Unexpected server error)都是「重试/换将
683
+ # 就可能活」的瞬态病,旧表判成终态导致整链早死
684
+ "stream disconnected", "unexpected server error")
604
685
 
605
686
 
606
687
  def _transient_error(err):
@@ -809,8 +890,21 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
809
890
  stdin_text = prompt
810
891
  elif kind == "aider":
811
892
  argv = resolve_command(agent["command"]) + [
812
- "--yes-always", "--no-auto-commits", "--no-check-update", "--message", prompt]
893
+ "--yes-always", "--no-auto-commits", "--no-check-update",
894
+ # 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
895
+ # 纯属刷屏(2026-09-20 实测占满步骤日志头部)
896
+ "--no-show-model-warnings", "--message", prompt]
813
897
  if model:
898
+ # litellm 靠 provider 前缀路由,裸模型名直接报
899
+ # "LLM Provider NOT provided"。前缀按本条尝试实际注入的端点协议定:
900
+ # ANTHROPIC_BASE_URL=anthropic 面(Bigmodel 等)、OPENAI_API_BASE=
901
+ # openai 兼容面;都没有=CLI 本机默认场景,模型名保持用户原样
902
+ envd = agent.get("env") or {}
903
+ if "/" not in model:
904
+ if envd.get("ANTHROPIC_BASE_URL"):
905
+ model = "anthropic/" + model
906
+ elif envd.get("OPENAI_API_BASE"):
907
+ model = "openai/" + model
814
908
  argv += ["--model", model]
815
909
  else: # generic:模板把 {prompt}/{session} 嵌进参数(注意 cmd 行长度限制)
816
910
  tmpl = agent.get("argv_template") or ["-p", "{prompt}"]
@@ -968,7 +1062,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
968
1062
  # stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
969
1063
  # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
970
1064
  # 里——只取其一会让 _quota_error/_transient_error 判空。
971
- tail = ((res["stderr"] or "") + "\n" + (res["stdout"] or "")).strip()[-600:]
1065
+ # 先洗后切:切片会割断 ANSI 序列,留下 `[91m` 这种裸残尾直接进 UI
1066
+ tail = clean_cli_text(
1067
+ ((res["stderr"] or "") + "\n" + (res["stdout"] or ""))[-4000:]).strip()[-600:]
972
1068
  head = ("输出停滞 %ss(stall timed out,疑似卡死已提前终止)" % stall_t
973
1069
  if res.get("stalled")
974
1070
  else "超时" if res["timed_out"]
@@ -1025,7 +1121,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1025
1121
  out["error"] = "codex: %s" % fm
1026
1122
  out["error_code"] = ErrorCode.VENDOR_ERROR
1027
1123
  elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
1028
- out["text"] = res["stdout"][-2000:]
1124
+ out["text"] = clean_cli_text(res["stdout"][-2000:])
1029
1125
  if require_tools and out["ok"] and _codex_work_events(res["stdout"]) == 0:
1030
1126
  # 实现步空转闸:exit 0 + 有话但零动手 → 判拒绝(fatal 语义正确,
1031
1127
  # 不把谎报的「已改完」静默传给下游;auto 模式换将逻辑按 ok 触发,
@@ -1060,7 +1156,10 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1060
1156
  out["error_code"] = _classify_failure(
1061
1157
  res, kind="claude", parsed=parsed, attempt_done=True)
1062
1158
  else:
1063
- out["text"] = res["stdout"].strip()
1159
+ # generic(aider/opencode/kimi…)的 stdout 就是终端转录:ANSI 颜色、
1160
+ # \r 进度条、画线框全在里面。不清洗的话它同时污染两处——步骤摘要
1161
+ # (详情页卡片显示成 ◆/─ 墙)与评审 JSON 解析(转义混进正文)
1162
+ out["text"] = clean_cli_text(res["stdout"]).strip()
1064
1163
  if not out["text"]:
1065
1164
  out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
1066
1165
  break
@@ -1162,6 +1261,27 @@ def as_scores(gj):
1162
1261
  return gj if isinstance(gj, dict) else None
1163
1262
 
1164
1263
 
1264
+ def extract_scores_from_text(text):
1265
+ """评审解析第四道网(借鉴 BAML 的宽容提取):模型把分数写成散文键值对
1266
+ 完全不出 JSON 时(2026-09-18 真实案例:kimi 正文提分),从文本直接抓
1267
+ 「维度:N 分」模式。返回 scores dict 或 {}。"""
1268
+ if not text:
1269
+ return {}
1270
+ scores = {}
1271
+ for m in re.finditer(
1272
+ r"[\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9 ]{0,11}"
1273
+ r"[::]\s*(\d{1,2}(?:\.\d)?)\s*分?", text):
1274
+ dim = m.group(0).rsplit(":", 1)[0].rsplit(":", 1)[0].strip()
1275
+ try:
1276
+ val = float(m.group(1))
1277
+ except ValueError:
1278
+ continue
1279
+ if not dim or dim in scores or not (1.0 <= val <= 10.0):
1280
+ continue
1281
+ scores[dim] = round(val, 1)
1282
+ return scores if len(scores) >= 3 else {}
1283
+
1284
+
1165
1285
  def scores_from_prose(text, dims):
1166
1286
  """评审解析第三道网:agentic CLI 有时把 JSON 写进文件、stdout 只留中文
1167
1287
  总结(「情节 8 / 人物 8 / …」)。JSON 全灭后按维度名从正文提分;