codebee 0.1.6 → 0.1.8

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.
@@ -79,6 +79,35 @@ def _agents():
79
79
  return registry.effective_agents(catalog.load(), manager.detect_all())
80
80
 
81
81
 
82
+ # 本轮运行的智能体池:execute_run 入口快照,_run_step 死链补位时扫描。
83
+ # 直接调 _run_step 的场景(单测/内部工具)池为空 → 补位不触发,闸门语义不变。
84
+ _CURRENT_AGENTS: list = []
85
+
86
+
87
+ def _dead_binding_substitute(dead_id, resume=None):
88
+ """死链补位:原定 CLI 配了链但解析为空(供应商停删/无密钥/**协议不匹配**——
89
+ 如 chat-only 供应商挂在只讲 responses 的 codex 链上)时,从同池找一个
90
+ 「配置过且链还活着」的真实 CLI 顶上——用户配的其它供应商继续干活,
91
+ 而不是整步判死。全池无活链才维持失败:死链闸门「绝不静默偷跑本机默认」
92
+ 的语义不变,补位用的仍是用户显式配好的链。
93
+
94
+ resume 会话钉在原 CLI 上(会话跟人走),有 resume 时补位无意义,直接不找。
95
+ 返回 bind_agent 之后的替代者,或 None。"""
96
+ if resume:
97
+ return None
98
+ from . import modelhub as _mh
99
+ for a in _CURRENT_AGENTS or []:
100
+ if a.get("id") == dead_id or a.get("mode") != "real":
101
+ continue
102
+ try:
103
+ cand = _mh.bind_agent(a, "default")
104
+ except Exception:
105
+ continue
106
+ if cand.get("binding_configured") and (cand.get("call_chain") or cand.get("env")):
107
+ return cand
108
+ return None
109
+
110
+
82
111
  def _pick(agents, agent_id):
83
112
  for a in agents:
84
113
  if a["id"] == agent_id:
@@ -286,26 +315,30 @@ def _binding_dead_msg(agent):
286
315
  def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None, require_tools=False):
287
316
  """执行一个智能体步骤并记录。返回 runner 统一结果。"""
288
317
  _wait_gate(run_id, ev)
289
- # 绑定解析为空:旧语义是回落 CLI 本机默认继续跑,2026-09-16 实测这种状态
290
- # 会静默烧本机默认供应商的配额(用户以为在用自己配的模型)。2026-09-17
291
- # 改为「告警 + 本步直接判失败」——宁可失败不静默降级;auto 流程实现步的
292
- # 既有换将会接手健康 CLI,真没有可用 CLI 时运行以明确的绑定错误收场。
318
+ # 绑定解析为空分两种(2026-09-18 起 区分对待):
319
+ # · 从没配过链(binding_configured=False):回落 CLI 本机默认照跑——
320
+ # 用户根本没在 CodeBee 里配供应商,谈不到「烧自己配的配额」;判失败
321
+ # 反而把用本地登录的普通用户全挡在门外(0.1.6 真实装机误伤案例)。
322
+ # · 配过链但全死(binding_configured=True):先看同池有没有「配置过且链还
323
+ # 活着」的 CLI 可补位(评审补位的全步骤版——用户的其它供应商继续干活,
324
+ # 典型场景:chat-only 供应商挂在只讲 responses 的 codex 链上必然解析为
325
+ # 空,2026-09-18 重写任务 3/3 续跑全灭案);全池无活链才判失败不静默
326
+ # 降级——宁可失败不偷跑本机默认。只记在步骤备注/错误里,不产生健康胶囊。
293
327
  dead_binding = (agent.get("mode") == "real"
328
+ and agent.get("binding_configured")
294
329
  and not (agent.get("call_chain") or agent.get("env")))
330
+ if dead_binding:
331
+ sub = _dead_binding_substitute(agent.get("id"), resume=resume)
332
+ if sub is not None:
333
+ note = ((note + ";") if note else "") + (
334
+ "⚠ 原定 %s 绑定链全部失效,已补位 %s"
335
+ % (agent.get("label") or agent["id"],
336
+ sub.get("label") or sub["id"]))
337
+ agent = sub
338
+ dead_binding = False
295
339
  dead_msg = _binding_dead_msg(agent) if dead_binding else ""
296
340
  if dead_binding:
297
- try:
298
- from . import health
299
- health.report_binding_dead(agent["id"], dead_msg)
300
- except Exception:
301
- pass
302
341
  note = ((note + ";") if note else "") + "⚠ " + dead_msg
303
- elif agent.get("mode") == "real":
304
- try: # 绑定恢复:自动解除该 CLI 的静态死链告警
305
- from . import health
306
- health.report_binding_ok(agent["id"])
307
- except Exception:
308
- pass
309
342
  step, log_abs = store.add_step(run_id, role, agent["id"],
310
343
  agent.get("label", agent["id"]), note=note)
311
344
  start = time.time()
@@ -359,12 +392,15 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
359
392
  return res
360
393
 
361
394
 
362
- def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None):
395
+ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None,
396
+ followups=False):
363
397
  """内置智能体步骤:直连模型 API + 工具循环(builtin_agent),不经 CLI 进程。
364
398
 
365
399
  与 _run_step 对齐的三件事:暂停/取消闸门、运行中指令 drain 注入、重复调用
366
400
  守门;结果同样经 _finish_step_result 落步骤(output=干净回答)并入用量台账。
367
- 日志只有「迭代/工具」摘要行——对话视图吃 output,日志抽屉看工具轨迹。"""
401
+ 日志只有「迭代/工具」摘要行——对话视图吃 output,日志抽屉看工具轨迹。
402
+ followups=True 时从回答末尾解析「建议追问」块(直连对话专用协议):
403
+ 剥离出结构化列表落步骤记录,正文保持干净。"""
368
404
  _wait_gate(run_id, ev)
369
405
  step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note)
370
406
  start = time.time()
@@ -393,6 +429,11 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
393
429
  lines.append(str(line))
394
430
 
395
431
  res = builtin_agent.run(bi, prompt, workdir, cancel_event=ev, log=_log, images=images)
432
+ if followups and res.get("ok"):
433
+ clean, fups = _parse_followups(res.get("text") or "")
434
+ if fups:
435
+ res["text"] = clean
436
+ res["followups"] = fups
396
437
  if log_abs:
397
438
  try:
398
439
  log_abs.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -505,7 +546,23 @@ def _finish_step_result(run_id, step, res, role, agent, start):
505
546
  model=res.get("model"),
506
547
  # 智能体的最终回答(runner 已从 JSONL 事件流里抽出 agent_message)。
507
548
  # 对话视图直读这个;日志文件是全量事件流,塞进气泡就成了「看日志」。
508
- output=(res.get("text") or ""))
549
+ output=(res.get("text") or ""),
550
+ followups=res.get("followups"))
551
+ # 错误台账:失败/超时各记一条结构化记录(遥测与诊断包的数据源)。
552
+ # 用户主动取消不入账——那不是产品问题;detail 只存脱敏后的失败摘录。
553
+ if status in ("failed", "timeout"):
554
+ try:
555
+ from . import errorlog
556
+ prov = agent.get("provider") or {}
557
+ errorlog.record(
558
+ category="step", reason=str(res.get("error_code") or "UNKNOWN"),
559
+ detail=(res.get("error") or ""),
560
+ provider=(prov.get("name") if isinstance(prov, dict) else "") or "",
561
+ model=res.get("model") or "", tool=agent.get("kind", ""),
562
+ role=role, run_id=run_id, task_id=(store.get_run(run_id) or {}).get("task_id") or "",
563
+ step=step["n"], exit_code=res.get("raw", {}).get("exit_code"))
564
+ except Exception:
565
+ pass
509
566
  if agent.get("mode") != "mock":
510
567
  _record_usage(run_id, role, agent, res, source="pipeline", step=step["n"])
511
568
  # 5F:step 级运行时断言(只告警不阻断)
@@ -921,8 +978,9 @@ __CONTEXT__
921
978
 
922
979
  ## 要求
923
980
  - 能改直接改、能写直接写(限本工作目录内),产出文件一律 UTF-8 编码(PowerShell 写文件显式 -Encoding UTF8)。
981
+ - 正文直接交代结果与答案:不要写「本轮做了什么」这类开场总结,也不要「回复:」这类引导词。
924
982
  - 回复的最后一行单独输出一行交代结果:
925
- DIRECT_DONE: <一句话说明本轮做了什么、产出了哪些文件>
983
+ DIRECT_DONE: <一句话结果摘要(含产出文件)>
926
984
  这一行之后不要再输出任何内容。"""
927
985
 
928
986
  DIRECT_FOLLOWUP_PROMPT = """你在与用户的持续对话中。用户针对已有成果发来了新消息(见下方「用户实时指令」注入块),请接着处理。
@@ -932,8 +990,9 @@ __GOAL__
932
990
 
933
991
  ## 要求
934
992
  - 优先回应用户新消息(继续做/改/答疑均可),仍限本工作目录内。
993
+ - 正文开门见山直接回答:不要写「本轮做了什么」这类开场总结,也不要「回复:」这类引导词。
935
994
  - 回复的最后一行单独输出:
936
- DIRECT_DONE: <一句话说明本轮做了什么>
995
+ DIRECT_DONE: <一句话结果摘要>
937
996
  这一行之后不要再输出任何内容。"""
938
997
 
939
998
  DIRECT_MAX_TURNS = 200 # 对话续轮上限(每轮都要用户主动发消息才触发,防意外打满)
@@ -948,7 +1007,8 @@ __CONTEXT__
948
1007
 
949
1008
  ## 要求
950
1009
  - 能改直接改、能写直接写(用工具,限本工作目录内),产出文件一律 UTF-8 编码。
951
- - 完成后直接给用户一段简短说明:做了什么、产出/修改了哪些文件。"""
1010
+ - 完成后直接给用户结论与答案,需要时顺带交代产出/修改了哪些文件;不要写「本轮做了什么」这类开场白。
1011
+ __FOLLOWUPS__"""
952
1012
 
953
1013
  BUILTIN_FOLLOWUP_PROMPT = """## 原始任务
954
1014
  __GOAL__
@@ -958,7 +1018,48 @@ __PREV__
958
1018
 
959
1019
  ## 要求
960
1020
  - 优先回应用户的新消息(继续做/改/答疑均可),仍限本工作目录内,工具可用。
961
- - 回复直接说清本轮做了什么、答案是什么。"""
1021
+ - 直接给答案/结果,需要时再带一句改动说明;不要写「本轮做了什么」这类开场白,也不要「回复:」这类引导词。
1022
+ __FOLLOWUPS__"""
1023
+
1024
+ # 追问建议协议(借鉴 freebuff 的 suggest_followups):模型在正文后自带最多 3 条
1025
+ # 建议追问,后端解析成结构化字段、从正文剥离,前端渲染成可点芯片。省一次额外
1026
+ # 请求;模型不配合时自然没有芯片,无需兜底。
1027
+ FOLLOWUPS_PROTOCOL = """
1028
+ ## 回复末尾协议
1029
+ 正文全部写完之后,另起一段输出最多 3 条「建议追问」(用户最可能接着问的方向),格式严格如下;没有合适的方向就整个省略,绝不要输出空的或凑数的:
1030
+ <followups>
1031
+ 10 字内的短标签 | 用户点击后会原样发送的完整消息(第一人称,一句话)
1032
+ </followups>"""
1033
+
1034
+
1035
+ def _parse_followups(text):
1036
+ """从回答正文里解析并剥离 <followups> 块。返回 (干净正文, [建议列表])。
1037
+
1038
+ 每行「标签 | 消息」(兼容全角|),最多取 3 条,两端空白与空行忽略;
1039
+ 没有块或解析不出任何合法行时原样返回。"""
1040
+ import re as _re
1041
+ if not text or "<followups>" not in text:
1042
+ return text, []
1043
+ m = _re.search(r"<followups>\s*([\s\S]*?)</followups>", text)
1044
+ if not m:
1045
+ return text, []
1046
+ out = []
1047
+ for line in m.group(1).splitlines():
1048
+ line = line.strip().lstrip("-•* ").strip()
1049
+ if not line:
1050
+ continue
1051
+ parts = _re.split(r"[||]", line, 1)
1052
+ if len(parts) != 2:
1053
+ continue
1054
+ label = parts[0].strip()
1055
+ msg = parts[1].strip()
1056
+ if not label or not msg:
1057
+ continue
1058
+ out.append({"label": label[:24], "prompt": msg[:200]})
1059
+ if len(out) >= 3:
1060
+ break
1061
+ clean = (text[:m.start()] + text[m.end():]).strip()
1062
+ return clean, out
962
1063
 
963
1064
 
964
1065
  def _pending_messages(run_id):
@@ -1056,7 +1157,8 @@ def _run_direct(run, task, agents, ev, stats, mode):
1056
1157
  if bi is not None:
1057
1158
  prompt = (BUILTIN_DIRECT_PROMPT
1058
1159
  .replace("__GOAL__", task["goal"])
1059
- .replace("__CONTEXT__", task.get("context") or "(无)"))
1160
+ .replace("__CONTEXT__", task.get("context") or "(无)")
1161
+ .replace("__FOLLOWUPS__", FOLLOWUPS_PROTOCOL))
1060
1162
  else:
1061
1163
  prompt = (DIRECT_PROMPT
1062
1164
  .replace("__GOAL__", task["goal"])
@@ -1067,7 +1169,8 @@ def _run_direct(run, task, agents, ev, stats, mode):
1067
1169
  if bi is not None:
1068
1170
  prompt = (BUILTIN_FOLLOWUP_PROMPT
1069
1171
  .replace("__GOAL__", task["goal"])
1070
- .replace("__PREV__", (last_text or "(无)")[-3000:]))
1172
+ .replace("__PREV__", (last_text or "(无)")[-3000:])
1173
+ .replace("__FOLLOWUPS__", FOLLOWUPS_PROTOCOL))
1071
1174
  else:
1072
1175
  prompt = DIRECT_FOLLOWUP_PROMPT.replace("__GOAL__", task["goal"])
1073
1176
  if not sid and last_text:
@@ -1083,7 +1186,8 @@ def _run_direct(run, task, agents, ev, stats, mode):
1083
1186
  before_n = len(_pending_messages(run_id))
1084
1187
  if bi is not None:
1085
1188
  res = _run_builtin_step(run_id, "direct" if first else "chat", bi, prompt,
1086
- step_wd, ev=ev, note=note, images=images)
1189
+ step_wd, ev=ev, note=note, images=images,
1190
+ followups=True)
1087
1191
  else:
1088
1192
  res = _run_step(run_id, "direct" if first else "chat", impl, prompt, step_wd,
1089
1193
  readonly=False, ev=ev, note=note,
@@ -1298,6 +1402,24 @@ def _read_text_any_enc(p):
1298
1402
  return runner.read_text_any_enc(p)
1299
1403
 
1300
1404
 
1405
+ def _critique_json(res, dims):
1406
+ """评审输出解析三道网,返回统一形状 {scores, issues, summary}:
1407
+ ① extract_json(严格 JSON → 围栏 → 宽松修复内嵌引号 → 花括号扫描)
1408
+ ② as_scores(兜底扫描掉进内层时,返回值本身就是 维度→分 本体,包回)
1409
+ ③ scores_from_prose(agentic CLI 把 JSON 写进文件、stdout 只留中文总结)
1410
+ 任何一道出分即算有效评审——「无法解析」绝不能把正常出分的评审吞掉
1411
+ (2026-09-18 七猫案:kimi 内嵌引号病连烧三轮自动续跑全判评审全挂)。"""
1412
+ text = res.get("text") or ""
1413
+ gj = runner.as_scores(runner.extract_json(text))
1414
+ if isinstance(gj, dict) and isinstance(gj.get("scores"), dict) and gj.get("scores"):
1415
+ return gj
1416
+ prose = runner.scores_from_prose(text, dims)
1417
+ if prose:
1418
+ return {"scores": prose, "issues": [], "summary": text[:400]}
1419
+ return {"scores": {}, "issues": [],
1420
+ "summary": "评审输出无法解析:%s" % (text or res.get("error") or "")[:150]}
1421
+
1422
+
1301
1423
  def _chapter_io(workdir, i, mode):
1302
1424
  """打开第 i 章文件;open 紧邻边界校验,路径越界直接拒绝(形态同 _ms_io)。
1303
1425
  读模式兼容 GBK 落盘的章稿(见 _read_text_any_enc)。"""
@@ -1521,13 +1643,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1521
1643
  if lens else "") + note_extra,
1522
1644
  workdir, readonly=True, ev=ev,
1523
1645
  resume=sids.get(agent["id"]))
1524
- cj = runner.extract_json(res.get("text") or "")
1525
- if not isinstance(cj, dict) or not isinstance(cj.get("scores"), dict) \
1526
- or not cj.get("scores"):
1527
- cj = {"scores": {}, "issues": [],
1528
- "summary": "评审输出无法解析:%s" % (res.get("text")
1529
- or res.get("error") or "")[:150]}
1530
- else:
1646
+ cj = _critique_json(res, dims)
1647
+ if cj.get("scores"):
1531
1648
  scored += 1
1532
1649
  # §07 T1.1:记录该评审的会话 id(第 2 轮复用)
1533
1650
  csid = _resume_sid(agent, res.get("sid"))
@@ -1876,40 +1993,70 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1876
1993
 
1877
1994
  # ---- 3) 全局一致性评审(覆盖 1..end 全书:续写批次必须连同旧章一起查一致性)
1878
1995
  full_text = "\n\n".join(_read_chapter(workdir, i) for i in range(1, end + 1))
1879
- global_means, global_issues = {}, []
1880
- for agent in critics:
1881
- role = "global-critique"
1882
- if agent.get("mode") == "mock":
1883
- step, _ = store.add_step(run_id, role, agent["id"], agent.get("label"))
1884
- time.sleep(0.15)
1885
- gj = {"scores": {d: 8.0 for d in dims},
1886
- "issues": [], "summary": "(mock)全书结构完整,达到可签约水平"}
1887
- store.finish_step(run_id, step["n"], "done", summary="均分 8.0:(mock)全书达标",
1888
- duration_s=0.15)
1889
- else:
1890
- gtpl = SERIAL_GLOBAL_PROMPT
1891
- if bible:
1892
- gtpl = gtpl.replace("## 全书目标", bible + "\n\n## 全书目标", 1)
1893
- res = _run_step(run_id, role, modelhub.bind_agent(agent, difficulty),
1894
- (gtpl.replace("__DIMKEYS__", dimkey)
1895
- .replace("__GOAL__", task["goal"])
1896
- .replace("__MANUSCRIPT__", full_text[:60000])),
1897
- workdir, readonly=True, ev=ev, timeout=2400)
1898
- gj = runner.extract_json(res.get("text") or "")
1899
- if not isinstance(gj, dict) or not isinstance(gj.get("scores"), dict):
1900
- gj = {"scores": {}, "issues": [], "summary": "全局评审输出无法解析"}
1901
- global_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
1902
- for d in dims:
1903
- v = gj.get("scores", {}).get(d)
1904
- if v is not None:
1905
- global_means.setdefault(d, []).append(float(v))
1906
- _check_cancel(ev)
1907
- global_means = {d: round(sum(xs) / len(xs), 1) for d, xs in global_means.items()}
1996
+ global_issues = []
1997
+
1998
+ def run_global_round(agent_list):
1999
+ """一轮全局评审:返回 (出分评审数, 按维累计分)。失败/不可解析不得当成低分计入。"""
2000
+ gmeans_acc, scored = {}, 0
2001
+ for agent in agent_list:
2002
+ if agent.get("mode") == "mock":
2003
+ step, _ = store.add_step(run_id, "global-critique", agent["id"],
2004
+ agent.get("label"))
2005
+ time.sleep(0.15)
2006
+ gj = {"scores": {d: 8.0 for d in dims},
2007
+ "issues": [], "summary": "(mock)全书结构完整,达到可签约水平"}
2008
+ store.finish_step(run_id, step["n"], "done", summary="均分 8.0:(mock)全书达标",
2009
+ duration_s=0.15)
2010
+ scored += 1
2011
+ else:
2012
+ gtpl = SERIAL_GLOBAL_PROMPT
2013
+ if bible:
2014
+ gtpl = gtpl.replace("## 全书目标", bible + "\n\n## 全书目标", 1)
2015
+ res = _run_step(run_id, "global-critique", modelhub.bind_agent(agent, difficulty),
2016
+ (gtpl.replace("__DIMKEYS__", dimkey)
2017
+ .replace("__GOAL__", task["goal"])
2018
+ .replace("__MANUSCRIPT__", full_text[:60000])),
2019
+ workdir, readonly=True, ev=ev, timeout=2400)
2020
+ gj = _critique_json(res, dims)
2021
+ if gj.get("scores"):
2022
+ scored += 1
2023
+ global_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
2024
+ for d in dims:
2025
+ v = gj.get("scores", {}).get(d)
2026
+ if v is not None:
2027
+ gmeans_acc.setdefault(d, []).append(float(v))
2028
+ _check_cancel(ev)
2029
+ return scored, gmeans_acc
2030
+
2031
+ gscored, gmeans_acc = run_global_round(critics)
2032
+ # 「评不上」≠「评了低分」:全局评审全挂时先从其它真实智能体补位(对齐章级
2033
+ # 评审者级 fallback);补位后仍零分则判 run 失败——「无法评审」绝不能当成
2034
+ # 「全局评审未通过」去盖「未达标」章(2026-09-18 假未达标案:codex 绑定链
2035
+ # 全失效 + kimi 命令行超长,global_scores 为空被 _all_ge 判成不通过)。
2036
+ # mock 评审总出分,不会误触;判失败不设 impl mock 例外(对齐章级中止)。
2037
+ if not gscored:
2038
+ tried = {a.get("id") for a in critics}
2039
+ for spare in [a for a in (agents or [])
2040
+ if a.get("mode") == "real" and a.get("id") not in tried][:2]:
2041
+ sc, acc = run_global_round([spare])
2042
+ for d, xs in acc.items():
2043
+ gmeans_acc.setdefault(d, []).extend(xs)
2044
+ gscored += sc
2045
+ if gscored:
2046
+ break
2047
+ if not gscored:
2048
+ store.update_run(run_id, status="failed",
2049
+ error="全局一致性评审全部失败(评审模型不可用或输出不可解析),"
2050
+ "已中止以免把「无法评审」误判为「未达标」。"
2051
+ "各章稿件已全部落盘,修复评审链后续跑可直接收尾",
2052
+ ended_at=_now())
2053
+ return
2054
+ global_means = {d: round(sum(xs) / len(xs), 1) for d, xs in gmeans_acc.items()}
1908
2055
  global_pass = _all_ge(global_means, threshold)
1909
2056
 
1910
2057
  # ---- 3.5) 自驱打磨:全局评审不过 → 自动重改最弱章并重评(至多 2 轮,无需人工)
1911
2058
  polish_rounds = 0
1912
- while (not global_pass) and polish_rounds < 2 and chapter_scores:
2059
+ while (not global_pass) and polish_rounds < 2 and chapter_scores and global_means:
1913
2060
  polish_rounds += 1
1914
2061
  weak = _weakest_chapters(chapter_scores, global_means, threshold, limit=2)
1915
2062
  if not weak:
@@ -1959,6 +2106,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1959
2106
  cj = {"scores": {}, "issues": [], "summary": "评审输出无法解析"}
1960
2107
  cj_by_agent[agent["id"]] = cj
1961
2108
  _check_cancel(ev)
2109
+ # 重评全挂(本轮所有评审都解析不出分数)→ 保留该章原分与达标态:
2110
+ # 拿「无法重评」覆盖真实分数,会把打磨前的好章误标 0 分、误判不达标
2111
+ repolished = any(isinstance(c.get("scores"), dict) and c["scores"]
2112
+ for c in cj_by_agent.values())
1962
2113
  vals = {}
1963
2114
  for d in dims:
1964
2115
  xs = [float(cj["scores"].get(d, 0)) for cj in cj_by_agent.values()
@@ -1966,38 +2117,22 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1966
2117
  vals[d] = round(sum(xs) / len(xs), 1) if xs else 0.0
1967
2118
  for c2 in chapter_scores:
1968
2119
  if c2["chapter"] == i:
1969
- c2["means"] = vals
1970
- c2["passed"] = bool(vals) and all(v >= threshold_ch for v in vals.values())
1971
- c2["rounds"] = int(c2.get("rounds") or 1) + 1
1972
- c2["polished"] = True
2120
+ if repolished:
2121
+ c2["means"] = vals
2122
+ c2["passed"] = bool(vals) and all(v >= threshold_ch for v in vals.values())
2123
+ c2["rounds"] = int(c2.get("rounds") or 1) + 1
2124
+ c2["polished"] = True
1973
2125
  c2["words"] = _wc(_read_chapter(workdir, i))
1974
2126
  fixed.append(i)
1975
2127
  store.update_run(run_id, chapter_scores=chapter_scores)
1976
2128
  _check_cancel(ev)
1977
- # 重评全书一致性
2129
+ # 重评全书一致性(同一评审闭包;本轮全挂则保留上一轮结论——评审链挂了
2130
+ # 不代表书变差,不能拿「无法评审」覆盖真实分数)
1978
2131
  full_text = "\n\n".join(_read_chapter(workdir, i2) for i2 in range(1, end + 1))
1979
- gmeans, gissues = {}, []
1980
- for agent in critics:
1981
- if agent.get("mode") == "mock":
1982
- gj2 = {"scores": {d: 8.0 for d in dims}, "issues": [],
1983
- "summary": "(mock)打磨后全书达标"}
1984
- else:
1985
- res3 = _run_step(run_id, "global-critique", modelhub.bind_agent(agent, difficulty),
1986
- (SERIAL_GLOBAL_PROMPT.replace("__DIMKEYS__", dimkey)
1987
- .replace("__GOAL__", task["goal"])
1988
- .replace("__MANUSCRIPT__", full_text[:60000])),
1989
- workdir, readonly=True, ev=ev, timeout=2400)
1990
- gj2 = runner.extract_json(res3.get("text") or "")
1991
- if not isinstance(gj2, dict) or not isinstance(gj2.get("scores"), dict):
1992
- gj2 = {"scores": {}, "issues": [], "summary": "全局评审输出无法解析"}
1993
- global_issues.extend({"chapter": "全书", **it} for it in (gj2.get("issues") or [])[:8])
1994
- for d in dims:
1995
- v = gj2.get("scores", {}).get(d)
1996
- if v is not None:
1997
- gmeans.setdefault(d, []).append(float(v))
1998
- _check_cancel(ev)
1999
- global_means = {d: round(sum(xs) / len(xs), 1) for d, xs in gmeans.items()}
2000
- global_pass = _all_ge(global_means, threshold)
2132
+ gscored2, gmeans_acc2 = run_global_round(critics)
2133
+ if gscored2:
2134
+ global_means = {d: round(sum(xs) / len(xs), 1) for d, xs in gmeans_acc2.items()}
2135
+ global_pass = _all_ge(global_means, threshold)
2001
2136
  store.finish_step(run_id, pstep["n"], "done" if global_pass else "failed",
2002
2137
  summary="重改 %s;打磨后全局 %s(%s)" % (
2003
2138
  "、".join("第 %d 章" % x for x in fixed),
@@ -2070,6 +2205,120 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2070
2205
  ended_at=_now())
2071
2206
 
2072
2207
 
2208
+ # ---- Best-of-N 赛马起草(非连载单稿;借鉴 freebuff editor-multi-prompt + best-of-n-selector)----
2209
+ # 连载路径的同章赛马是另一套(_run_serial_review 的 n_variants 分支,逐变体全维度
2210
+ # 评分),互不影响。赛马与压缩会话互斥(同连载赛马的守卫):并行 _run_step 会在
2211
+ # 同一 run 的会话事件流里交错,压缩面选择区域可能被搅乱。
2212
+
2213
+ # 每路候选的写法策略(刻意制造多样性,不是同提示词跑 N 遍);0 号不带策略=现状等价
2214
+ BESTOF_STRATEGIES = (
2215
+ "",
2216
+ "\n\n## 写法策略\n本稿以「场景与画面感」优先:多用具体可感的细节、动作与环境推进,少直接概括。",
2217
+ "\n\n## 写法策略\n本稿以「对话与冲突」驱动:让人物在对话与碰撞中推进情节,节奏明快、信息密度高。",
2218
+ )
2219
+
2220
+ BESTOF_SELECTOR_PROMPT = """你是终审编辑。同一个写作任务并行产生了多份候选稿,请对比选出最好的一份。
2221
+
2222
+ ## 任务目标
2223
+ __GOAL__
2224
+
2225
+ ## 对比要点(按重要性)
2226
+ 贴合任务要求与评审维度 > 结构与可读性 > 语言质量 > 不跑题、不注水
2227
+
2228
+ __CANDIDATES__
2229
+
2230
+ ## 输出
2231
+ 只输出一个 JSON 对象,不要输出任何其他内容:
2232
+ {"pick": <选中候选的编号(从 0 开始的整数)>, "reason": "<一句话理由>", "improvements": "<落选稿里值得吸收进选中稿的具体优点,多条用分号隔开;没有就给空字符串>"}
2233
+ """
2234
+
2235
+
2236
+ def _variant_name(ms_name, k):
2237
+ """候选稿文件名:manuscript.md → manuscript.v0.md(无扩展名则尾部追加)。"""
2238
+ m = re.search(r"(\.[^./\\]+)$", ms_name)
2239
+ return (ms_name[:m.start()] + ".v%d" % k + m.group(1)) if m else (ms_name + ".v%d" % k)
2240
+
2241
+
2242
+ def _bestof_draft(run_id, task, impl, prompt_fn, ms_name, workdir, step_wd, ev,
2243
+ resume_ctx, difficulty, best_of, sel_agent, write_ms, note=""):
2244
+ """非连载评审流的 Best-of-N 赛马起草。
2245
+
2246
+ N 路并行起草到各自变体文件(每路带不同写法策略)→ 终审选择器单次调用对比
2247
+ 择优并回收落选稿精华 → 胜者写回正式稿名。某路失败只弃那路;全败返回其中
2248
+ 一路的原始结果(保持原报错行为);选择器失败/不可解析回落 0 号候选(等价
2249
+ 单稿行为)。选择结果与败者精华记入 run.bestof,变体文件保留供用户比对。"""
2250
+ n = max(2, min(3, int(best_of)))
2251
+ results = {}
2252
+
2253
+ def _one(kk):
2254
+ vfile = _variant_name(ms_name, kk)
2255
+ p = prompt_fn(vfile) + BESTOF_STRATEGIES[kk % len(BESTOF_STRATEGIES)]
2256
+ r = _run_step(run_id, "draft-v%d" % kk,
2257
+ modelhub.bind_agent(impl, difficulty), p, step_wd,
2258
+ readonly=False, ev=ev,
2259
+ # 只有 0 号候选继承续会话(N 路共用同一 CLI 会话会互相践踏)
2260
+ resume=(resume_ctx["session"] if (resume_ctx and kk == 0) else None),
2261
+ images=_task_images(task, workdir),
2262
+ note=(note + " · " if note else "") + "候选 %d/%d" % (kk + 1, n))
2263
+ txt = ""
2264
+ try:
2265
+ txt = _read_text_any_enc(os.path.join(workdir, vfile))
2266
+ except Exception:
2267
+ txt = ""
2268
+ results[kk] = (vfile, r, (txt or "").strip())
2269
+
2270
+ threads = []
2271
+ for kk in range(n):
2272
+ th = threading.Thread(target=_one, args=(kk,),
2273
+ name="bestof-%s-v%d" % (run_id, kk), daemon=True)
2274
+ threads.append(th)
2275
+ th.start()
2276
+ for th in threads:
2277
+ th.join(3000)
2278
+ _check_cancel(ev)
2279
+
2280
+ candidates = []
2281
+ for kk in range(n):
2282
+ vfile, r, txt = results.get(kk, (None, None, ""))
2283
+ if r is not None and r.get("ok") and txt:
2284
+ candidates.append({"k": kk, "file": vfile, "res": r, "text": txt})
2285
+ if not candidates:
2286
+ r = (results.get(0) or results.get(n - 1) or (None, None, ""))[1]
2287
+ return (r or {"ok": False, "error": "全部候选起草失败"}), None
2288
+
2289
+ # 终审选择器:单次调用对比全部候选(结构化输出 + 败者精华回收)
2290
+ cand_blocks = "\n\n".join(
2291
+ "### 候选 %d\n\n%s" % (c["k"], c["text"]) for c in candidates)
2292
+ sel_prompt = (BESTOF_SELECTOR_PROMPT
2293
+ .replace("__GOAL__", task["goal"])
2294
+ .replace("__CANDIDATES__", cand_blocks))
2295
+ pick, reason, improvements = candidates[0]["k"], "", ""
2296
+ sel_res = _run_step(run_id, "bestof-select",
2297
+ modelhub.bind_agent(sel_agent, difficulty),
2298
+ sel_prompt, workdir, readonly=True, ev=ev, note="候选择优")
2299
+ cj = runner.extract_json(sel_res.get("text") or "") if sel_res.get("ok") else None
2300
+ if isinstance(cj, dict):
2301
+ try:
2302
+ pk = int(cj.get("pick"))
2303
+ except Exception:
2304
+ pk = -1
2305
+ if any(c["k"] == pk for c in candidates):
2306
+ pick = pk
2307
+ reason = str(cj.get("reason") or "")[:300]
2308
+ improvements = str(cj.get("improvements") or "")[:600]
2309
+ winner = next(c for c in candidates if c["k"] == pick)
2310
+ winner["reason"] = reason
2311
+ winner["improvements"] = improvements
2312
+ write_ms(winner["text"])
2313
+ store.update_run(run_id, bestof={
2314
+ "pick": winner["k"], "file": winner["file"],
2315
+ "candidates": [c["k"] for c in candidates],
2316
+ "reason": reason, "improvements": improvements,
2317
+ "selector": (sel_agent.get("id") or "") if isinstance(sel_agent, dict) else "",
2318
+ })
2319
+ return winner["res"], winner
2320
+
2321
+
2073
2322
  def _run_content_review(run, task, agents, ev, stats, mode):
2074
2323
  run_id = run["id"]
2075
2324
  workdir = task["workdir"]
@@ -2126,6 +2375,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2126
2375
  store.update_run(run_id, outline=outline)
2127
2376
 
2128
2377
  # 1) 起草
2378
+ bestof_improvements = "" # 赛马败者精华(真实路径由选择器填充;mock 路径恒空)
2129
2379
  if impl.get("mode") == "mock":
2130
2380
  step, log_abs = store.add_step(run_id, "draft", impl["id"], impl.get("label"), note=draft_note)
2131
2381
  write_ms(mocks.draft_manuscript(task, 1))
@@ -2137,16 +2387,32 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2137
2387
  except Exception:
2138
2388
  pass
2139
2389
  else:
2140
- prompt = (_tpl(task, "draft_prompt", NOVEL_DRAFT_PROMPT).replace("__FILE__", ms_name)
2141
- .replace("__GOAL__", task["goal"])
2142
- .replace("__CONTEXT__", task.get("context") or "(无)"))
2143
- if outline:
2144
- prompt += "\n\n## 编排者大纲(按要点组织稿件)\n" + \
2145
- "\n".join("- " + i for i in outline["items"])
2146
- draft_res = _run_step(run_id, "draft", modelhub.bind_agent(impl, difficulty), prompt,
2147
- step_wd, readonly=False, ev=ev, note=draft_note,
2148
- resume=resume_ctx["session"] if resume_ctx else None,
2149
- images=_task_images(task, workdir))
2390
+ def _draft_prompt_for(vfile):
2391
+ p = (_tpl(task, "draft_prompt", NOVEL_DRAFT_PROMPT).replace("__FILE__", vfile)
2392
+ .replace("__GOAL__", task["goal"])
2393
+ .replace("__CONTEXT__", task.get("context") or "(无)"))
2394
+ if outline:
2395
+ p += "\n\n## 编排者大纲(按要点组织稿件)\n" + \
2396
+ "\n".join("- " + i for i in outline["items"])
2397
+ return p
2398
+
2399
+ best_of = max(1, min(3, int(task.get("best_of") or 1)))
2400
+ if best_of >= 2 and not _compaction_enabled():
2401
+ try:
2402
+ sel_agent = critics[0]
2403
+ except Exception:
2404
+ sel_agent = impl
2405
+ draft_res, _bw = _bestof_draft(
2406
+ run_id, task, impl, _draft_prompt_for, ms_name, workdir, step_wd,
2407
+ ev, resume_ctx, difficulty, best_of, sel_agent, write_ms,
2408
+ note=draft_note)
2409
+ bestof_improvements = (_bw or {}).get("improvements") or ""
2410
+ else:
2411
+ draft_res = _run_step(run_id, "draft", modelhub.bind_agent(impl, difficulty),
2412
+ _draft_prompt_for(ms_name), step_wd, readonly=False,
2413
+ ev=ev, note=draft_note,
2414
+ resume=resume_ctx["session"] if resume_ctx else None,
2415
+ images=_task_images(task, workdir))
2150
2416
  if not draft_res["ok"]:
2151
2417
  store.update_run(run_id, status="failed", error="起草失败: %s" % draft_res.get("error"),
2152
2418
  ended_at=_now())
@@ -2205,6 +2471,11 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2205
2471
  majors = [i for i in issues_all if i.get("severity") == "major"][:8]
2206
2472
  for i in majors:
2207
2473
  crit_lines.append("- [%s] %s" % (i.get("dim", "?"), str(i.get("note", ""))[:120]))
2474
+ if r == 1 and bestof_improvements:
2475
+ # 败者精华回收(freebuff suggestedImprovements 的落地):终审从落选
2476
+ # 候选稿提炼的优点,首轮修订时与评审意见一并喂给作者
2477
+ crit_lines.append("- [赛马精华] 终审择优时从落选候选稿提炼出值得吸收的优点:%s"
2478
+ % bestof_improvements[:400])
2208
2479
  if impl.get("mode") == "mock":
2209
2480
  step, log_abs = store.add_step(run_id, "revise-r%d" % r, impl["id"], impl.get("label"))
2210
2481
  write_ms(mocks.draft_manuscript(task, r + 1))
@@ -2274,6 +2545,54 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2274
2545
 
2275
2546
  # ---------------------------------------------------------------- 入口
2276
2547
 
2548
+ SERIAL_QA_PROMPT = """你是网文《__TITLE__》的责任编辑(不要修改任何文件)。读者(作者本人)就这本书提了一个问题,请直接回答:
2549
+
2550
+ __QUESTION__
2551
+
2552
+ 回答要求:
2553
+ - 只回答问题,不要重写章节、不要改动任何文件。
2554
+ - 先给结论,再给依据;涉及章节时点明具体文件(如 chapter-09.md)。
2555
+ - 问题若暴露了稿件自身毛病(缺章、编号错位、前后矛盾),说清楚坏在哪个文件、该怎么修。
2556
+
2557
+ 各章成稿都在当前工作目录(chapter-XX.md),合并稿 manuscript.md 可能只含部分章节,可按需查阅。"""
2558
+
2559
+
2560
+ def _run_serial_qa(run, task, agents, ev):
2561
+ """连载答疑轮:追话提问不再整本重跑——单步只读问答,答案落步骤 output,
2562
+ 对话页时间线(消息气泡 + 步骤正文)直接可读(2026-09-18「为啥没有第九章」
2563
+ 案:一句追问被当成完整编排指令,重评 8 章还烧出连环自动续跑)。"""
2564
+ run_id = run["id"]
2565
+ workdir = run.get("workdir") or task.get("workdir") or ""
2566
+ question = (run.get("qa_text") or "").strip() or "(见下方读者追问)"
2567
+ prompt = (SERIAL_QA_PROMPT
2568
+ .replace("__TITLE__", (task.get("title") or "本书").lstrip("# ").strip())
2569
+ .replace("__QUESTION__", question))
2570
+ # 回答者顺序:评审组(专职读稿)→ 实现者 → 其余已启用真实智能体;死链自动落到下一个
2571
+ critic_ids = task.get("critics") or []
2572
+ impl_id = task.get("implementer") or ""
2573
+ ordered = [a for a in agents if a.get("id") in critic_ids]
2574
+ ordered += [a for a in agents
2575
+ if a.get("id") == impl_id and a.get("id") not in critic_ids]
2576
+ ordered += [a for a in agents
2577
+ if a.get("mode") == "real"
2578
+ and a.get("id") not in critic_ids and a.get("id") != impl_id]
2579
+ ordered = [a for a in ordered if a.get("mode") != "mock"] or ordered[:1]
2580
+ errors = []
2581
+ for agent in ordered[:3]:
2582
+ _check_cancel(ev)
2583
+ res = _run_step(run_id, "qa", modelhub.bind_agent(agent, "default"),
2584
+ prompt, workdir, readonly=True, ev=ev, timeout=1200)
2585
+ if res.get("ok") and (res.get("text") or "").strip():
2586
+ store.update_run(run_id, status="done", ended_at=_now(),
2587
+ verdict={"qa": True,
2588
+ "answered_by": agent.get("id")})
2589
+ return
2590
+ errors.append("%s:%s" % (agent.get("id"),
2591
+ (res.get("error") or "无输出")[:120]))
2592
+ store.update_run(run_id, status="failed", ended_at=_now(),
2593
+ error="答疑失败(执行/评审链不可用)——" + ";".join(errors[-3:]))
2594
+
2595
+
2277
2596
  def execute_run(run_id):
2278
2597
  run = store.get_run(run_id)
2279
2598
  if not run:
@@ -2309,6 +2628,8 @@ def execute_run(run_id):
2309
2628
  # 任务分支裁决状态:新一轮 run 产生新分支内容,重置回「待裁决」
2310
2629
  store.set_task_git_state(task["id"], "isolated")
2311
2630
  agents = _agents()
2631
+ global _CURRENT_AGENTS
2632
+ _CURRENT_AGENTS = agents
2312
2633
  # 续会话是对该 CLI 的显式指定:目标未启用编排时也注入本次运行(不影响路由池)
2313
2634
  want = ((task.get("resume") or {}).get("agent") or "").strip()
2314
2635
  if want and _pick(agents, want) is None:
@@ -2322,6 +2643,11 @@ def execute_run(run_id):
2322
2643
  # direct=单 CLI 直达(无拆解/评审,信箱续轮即对话)
2323
2644
  engine = task.get("engine") or ("code" if task["type"] == "code" else "review")
2324
2645
  try:
2646
+ # 连载答疑轮:op=qa 不走编排流水线,单步只读回答后即收尾;
2647
+ # 放进 try——Cancelled 与主流程同口径收口为 cancelled
2648
+ if run.get("op") == "qa":
2649
+ _run_serial_qa(run, task, agents, ev)
2650
+ return
2325
2651
  if engine == "code":
2326
2652
  _run_code(run, task, agents, ev, stats, mode)
2327
2653
  elif engine == "direct":