codebee 0.1.95 → 0.1.97

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.
Files changed (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,398 +1,398 @@
1
- # -*- coding: utf-8 -*-
2
- """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import re
8
-
9
- from . import dispatch, history, upstream
10
-
11
- # 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
12
- CAPABILITY = {
13
- "codex": 84, "claude": 84, "opencode": 74, "qwen": 72,
14
- "aider": 70, "openclaw": 60, "generic": 55, "mock": 10,
15
- }
16
-
17
- MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
18
- AUTO_CRITIC_LIMIT = 2 # 自动内容评审只取综合分前两名;手工模式仍尊重完整名单
19
-
20
-
21
- def _task_type(ttype):
22
- """兼容旧调用的字符串与新任务规格对象。"""
23
- if isinstance(ttype, dict):
24
- return str(ttype.get("type") or ttype.get("dimension") or "direct")
25
- return str(ttype or "direct")
26
-
27
-
28
- def _binding_bonus(agent_id, dispatch_mode=False):
29
- """绑定链可用性加分/减分:解析出的调用链有备胎(≥2 条)+8;单条 = 单点
30
- 无降级空间 +0;解析为空 -25。2026-09-16 实测:静态能力基线让配额烧干的
31
- codex 永远压过健康备用 CLI,绑定空的 CLI 更是连用户配置的模型都没用上
32
- ——先按「能不能按配置跑起来」校准。2026-09-22 续修:单条链(含空链+
33
- 仅 provider_id 的旧数据)拿过 +8 是信号失真——2026-09-22 智谱余额清零
34
- 实测「绑定链可用」的 claude/kimi 全是单点,无一家有第二口气。返回
35
- (分值, 理由文本)。"""
36
- try:
37
- from . import modelhub
38
- pref = modelhub._binding_for(agent_id)
39
- configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
40
- if not configured:
41
- return (0.0, "") if dispatch_mode else (-25.0, ",绑定链为空:相关步骤将判失败(-25.0)")
42
- b = modelhub.resolve_binding(agent_id)
43
- n = len((b or {}).get("call_chain") or [])
44
- if n >= 2:
45
- return 8.0, ",绑定链可用(+8.0)"
46
- if n == 1:
47
- return 0.0, ",绑定单点无备胎(+0)"
48
- return -25.0, ",绑定链为空:相关步骤将判失败(-25.0)"
49
- except Exception:
50
- return 0.0, ""
51
-
52
-
53
- def _history_bonus(stats, agent_id, ttype):
54
- s = (stats.get(agent_id) or {}).get(ttype) or {}
55
- if not s.get("runs"):
56
- return 0.0
57
- runs = s["runs"]
58
- win_rate = s["wins"] / runs
59
- # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
60
- # 偶发失败把智能体埋了)。扣分项封顶 -24,0/3 全败净 -21(含 +3 经验分)、
61
- # 0/6 净 -18:常挂的 CLI 必须排到无历史的新面孔之后(2026-09-17 前败率不
62
- # 扣分,0/3 还拿 +6 经验分,比没跑过还高)。
63
- loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
64
- return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
65
-
66
-
67
- def _online_bonus(agent, role, ttype):
68
- """近期真实运行信号:成功率、P95 延迟和均价只作软加减分。"""
69
- try:
70
- from . import usage
71
- metrics = usage.routing_stats(task_type=ttype, role=role,
72
- agent=agent.get("id") or "")
73
- samples = int(metrics.get("samples") or 0)
74
- if not samples:
75
- return 0.0, ""
76
- rate = float(metrics.get("success_rate") or 0.0)
77
- success_score = max(-8.0, min(8.0, (rate - 0.75) * 24.0))
78
- p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
79
- # 旧上限 -6 抵不过 CLI 静态能力 10 分差,P95 两三分钟的模型仍会
80
- # 压过十几秒的健康模型。加大惩罚,让真实延迟足以改变自动选择。
81
- latency_score = -min(18.0, max(0.0, (p95 - 30.0) / 7.0))
82
- cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
83
- cost_score = -min(4.0, max(0.0, (cost - 0.01) / 0.01))
84
- total = round(success_score + latency_score + cost_score, 1)
85
- success_samples = int(metrics.get("success_samples") or samples)
86
- reason = (",在线 %d/%d 验收成功(%+.1f),P95 %.1fs(%+.1f),均价 $%.4f(%+.1f)"
87
- % (int(metrics.get("successes") or 0), success_samples,
88
- success_score, p95, latency_score, cost, cost_score))
89
- # 标准要求回退层出现在候选理由里(成本与时延预算第 5 条):
90
- # exact/task-role/task/global,让"分数来自哪层样本"可核对。
91
- reason += ",样本层 %s" % (metrics.get("fallback") or "global")
92
- return total, reason
93
- except Exception:
94
- return 0.0, ""
95
-
96
-
97
- def score(agent, role, ttype, stats=None):
98
- """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
99
- quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
100
- (封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
101
- stats = stats or {}
102
- ttype = _task_type(ttype)
103
- base = CAPABILITY.get(agent.get("kind"), 60)
104
- use_dispatch = bool(agent.get("_dispatch_task_type") or
105
- agent.get("dispatch_enabled"))
106
- bb, btxt = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
107
- hb = _history_bonus(stats, agent.get("id"), ttype)
108
- online, online_txt = _online_bonus(agent, role, ttype)
109
- # 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
110
- # 才启用能力亲和度,避免旧插件/测试调用被新权重悄然改变。
111
- affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
112
- if use_dispatch else (0.0, "兼容模式"))
113
- total = base + bb + hb + affinity + online
114
- hs = (stats.get(agent.get("id")) or {}).get(ttype)
115
- htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
116
- quota_txt = ""
117
- quota = int(agent.get("quota_tokens_per_hour") or 0)
118
- if quota > 0:
119
- try:
120
- from . import usage
121
- used = usage.agent_tokens_recent(agent.get("id"), hours=1)
122
- except Exception:
123
- used = 0
124
- if used > 0:
125
- ratio = min(1.0, used / float(quota))
126
- penalty = round(-45.0 * ratio, 1)
127
- if penalty:
128
- total += penalty
129
- quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
130
- return total, "能力基线 %d,%s%s%s%s%s,总分 %s" % (
131
- base, affinity_txt, btxt, htxt, online_txt, quota_txt, round(total, 1))
132
-
133
-
134
- def pick(agents, role, ttype, stats=None, exclude=()):
135
- """按分选出最优智能体。返回 (agent, 理由) 或 (None, "")。"""
136
- stats = stats or history.agent_stats()
137
- best, best_reason = None, ""
138
- for a in agents:
139
- if a["id"] in exclude:
140
- continue
141
- total, reason = score(a, role, ttype, stats)
142
- if best is None or total > best[0]:
143
- best, best_reason = (total, a), reason
144
- if best is None:
145
- return None, ""
146
- return best[1], best_reason
147
-
148
-
149
- # 空链 CLI 的真实上游在各自本机配置里(注册表里的 provider_id 是 UI 残留,
150
- # 2026-09-22 实测 qwencode 注册表指 prov-36 本机却指公司网关)。正则探针
151
- # 只为换将时识别「同上游」,读不到就算未知——未知不参与剔除,宁白试不误杀。
152
- _LOCAL_ENDPOINT_PROBES = (
153
- ("kimi-code", "~/.kimi-code/config.toml", r'baseUrl\s*=\s*"([^"]+)"'),
154
- ("claude-code", "~/.claude/settings.json", r'"ANTHROPIC_BASE_URL"\s*:\s*"([^"]+)"'),
155
- ("qwencode", "~/.qwen/settings.json", r'"OPENAI_BASE_URL"\s*:\s*"([^"]+)"'),
156
- ("opencode", "~/.config/opencode/opencode.json", r'"baseURL"\s*:\s*"([^"]+)"'),
157
- ("opencode", "~/.config/opencode/opencode.jsonc", r'"baseURL"\s*:\s*"([^"]+)"'),
158
- ("codex-cli", "~/.codex/config.toml", r'base_url\s*=\s*"([^"]+)"'),
159
- )
160
-
161
- _PI_SETTINGS_PATH = "~/.pi/agent/settings.json"
162
- _PI_MODELS_PATH = "~/.pi/agent/models.json"
163
-
164
-
165
- def _pi_provider_hosts(selected_name):
166
- """按选中名取 pi 供应商块的 host:port(先 models.json,再 settings.json 兜底)。
167
-
168
- 老配置可能把 providers 直接写进 settings.json,两件都认;只认选中的那块。"""
169
- hosts = set()
170
- if not selected_name:
171
- return hosts
172
- for rel in (_PI_MODELS_PATH, _PI_SETTINGS_PATH):
173
- try:
174
- with open(os.path.expanduser(rel), "r", encoding="utf-8") as fh:
175
- cfg = json.load(fh)
176
- except (OSError, ValueError, TypeError):
177
- continue
178
- if not isinstance(cfg, dict):
179
- continue
180
- block = (cfg.get("providers") or {}).get(selected_name)
181
- if not isinstance(block, dict):
182
- continue
183
- h = _host_of(block.get("baseUrl") or block.get("baseURL") or
184
- block.get("base_url"))
185
- if h:
186
- hosts.add(h)
187
- break # 命中即定:models.json 优先,别让另一件的旧块参与判断
188
- return hosts
189
-
190
-
191
- def _host_of(url):
192
- """上游归一:路径、认证信息和默认端口不影响同一上游身份。"""
193
- return upstream.normalize_upstream(url)
194
-
195
-
196
- def agent_upstreams(agent_id, difficulty="default", task_type="", role=""):
197
- """候选实际会用到的上游集合(host:port 归一)。显式链优先;空绑定按
198
- 运行期自动推荐链判断,再回落到 CLI 本机配置;都拿不到返回空集 = 未知。"""
199
- ups = set()
200
- try:
201
- from . import modelhub
202
- pref = modelhub._binding_for(agent_id)
203
- provs = {p.get("id"): p for p in modelhub.providers()}
204
-
205
- def _add_chain(chain):
206
- for item in chain or ():
207
- pid = (item.get("provider_id") or "").strip()
208
- provider = item.get("provider") or provs.get(pid) or {}
209
- h = _host_of(provider.get("base_url"))
210
- if h:
211
- ups.add(h)
212
-
213
- configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
214
- # 保留原始显式链中的 endpoint,即使某条已禁用/失效;同一个死上游仍
215
- # 不值得在另一个 CLI 上立刻再撞一次。
216
- if configured:
217
- chain = modelhub._binding_chain(pref)
218
- _add_chain(chain)
219
- resolved = modelhub.resolve_binding(agent_id, difficulty) or {}
220
- _add_chain(resolved.get("call_chain"))
221
- else:
222
- # bind_agent 在空绑定时会自动推荐供应商链。换将选路必须观察同一
223
- # 生效链,否则“空绑定”的多个 CLI 可能被排成不同候选却共用一个 403 网关。
224
- recommended = modelhub.recommend_binding(
225
- agent_id, difficulty, task_type=task_type, role=role) or {}
226
- _add_chain(recommended.get("call_chain"))
227
- except Exception:
228
- pass
229
- if not ups:
230
- if agent_id == "pi":
231
- # Pi 的选择位在 settings.json(defaultProvider),而 endpoint 在**同目录
232
- # models.json** 的 providers.<选中名>.baseUrl(pi 的 getModelsPath(),
233
- # CodeBee 托管与用户手工配置都落在那儿)——不能只扫所有 baseUrl,
234
- # 那会把未选中的 provider 也误算成当前上游。
235
- try:
236
- with open(os.path.expanduser(_PI_SETTINGS_PATH), "r",
237
- encoding="utf-8") as fh:
238
- settings = json.load(fh)
239
- selected_name = str((settings.get("defaultProvider")
240
- if isinstance(settings, dict) else "") or "")
241
- ups.update(_pi_provider_hosts(selected_name))
242
- except (OSError, ValueError, TypeError):
243
- pass
244
- if not ups:
245
- for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
246
- if aid != agent_id:
247
- continue
248
- try:
249
- with open(os.path.expanduser(path), "r", encoding="utf-8",
250
- errors="ignore") as fh:
251
- txt = fh.read()
252
- except OSError:
253
- continue
254
- for m in re.finditer(pat, txt):
255
- h = _host_of(m.group(1))
256
- if h:
257
- ups.add(h)
258
- return ups
259
-
260
-
261
- def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
262
- dead_upstreams=(), difficulty="default",
263
- blocked_upstreams=()):
264
- """换将选将。配额类死亡背景下:与死者同上游的候选依次让位,直到找到
265
- 异上游/上游未知的候选(2026-09-22 实案:kimi 与 claude 同骑智谱,0.1 分
266
- 之差把异上游 qwencode 压在下面,换将=换壳不换命)。异上游耗尽后同上游
267
- 候选捡回分数最高者——聊胜于无。403 明确拒绝的上游始终阻断,不捡回重试。"""
268
- exclude = set(exclude)
269
- blocked_sets = [set(d) for d in (blocked_upstreams or ()) if d]
270
- best, reason = pick(agents, role, ttype, stats, exclude=exclude)
271
- if best is None or (not dead_upstreams and not blocked_sets):
272
- return best, reason
273
- deferred = []
274
- while best is not None:
275
- ups = agent_upstreams(best.get("id"), difficulty=difficulty,
276
- task_type=ttype, role=role)
277
- if ups and any(ups & blocked for blocked in blocked_sets):
278
- # 明确的 403 权限拒绝:同一上游没有换模型/换 CLI 再试的价值,
279
- # 即使异上游候选耗尽也不把它捡回来(与可换账号的配额错误不同)。
280
- exclude.add(best.get("id"))
281
- best, reason = pick(agents, role, ttype, stats, exclude=exclude)
282
- continue
283
- if not (ups and any(ups & dead for dead in dead_upstreams)):
284
- break # 异上游或上游未知:就用它
285
- deferred.append((best, reason))
286
- best, reason = pick(agents, role, ttype, stats, exclude=exclude | {
287
- x.get("id") for x, _ in deferred})
288
- if best is None and deferred:
289
- best, reason = deferred[0] # 异上游全灭:同上游里挑最高的顶上
290
- elif deferred:
291
- reason = ("%s(%s 与死者同上游,延后让位异上游候选)"
292
- % (reason, deferred[-1][0].get("id")))
293
- return best, reason
294
-
295
-
296
- def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
297
- participants=(), selection_reason=""):
298
- """生成可审计的候选排序,并可用实际选路覆盖评分预选结果。"""
299
- if stats is None:
300
- stats = history.agent_stats()
301
- # 与 pick 保持同一候选池;绑定/健康扣分仍由 score 和 modelhub 负责,
302
- # 诊断不能悄悄排除实际可能被选中的 mock 或备用 CLI。
303
- pool = list(agents or [])
304
- rows = []
305
- for index, agent in enumerate(pool):
306
- if agent.get("id") in exclude:
307
- continue
308
- candidate = dict(agent)
309
- if isinstance(task_spec, dict):
310
- candidate["_dispatch_task_type"] = task_spec.get("type") or "direct"
311
- candidate["_dispatch_role"] = role
312
- total, reason = score(candidate, role, task_spec, stats)
313
- rows.append({"agent_id": agent.get("id") or "",
314
- "label": agent.get("label") or agent.get("id") or "",
315
- "kind": agent.get("kind") or "generic",
316
- "score": round(total, 1), "reason": reason,
317
- "order": index})
318
- rows.sort(key=lambda x: (-x["score"], x["order"]))
319
- selected_id = (selected or {}).get("id") if isinstance(selected, dict) else ""
320
- if selected_id and not any(x["agent_id"] == selected_id for x in rows):
321
- rows.append({"agent_id": selected_id,
322
- "label": selected.get("label") or selected_id,
323
- "kind": selected.get("kind") or "builtin",
324
- "score": 0.0, "reason": selection_reason or "实际选路",
325
- "order": len(rows)})
326
- chosen = selected_id or (rows[0]["agent_id"] if rows else "")
327
- participant_ids = []
328
- for agent in participants or ():
329
- agent_id = agent.get("id") if isinstance(agent, dict) else str(agent or "")
330
- if agent_id and agent_id not in participant_ids:
331
- participant_ids.append(agent_id)
332
- active_ids = participant_ids or ([chosen] if chosen else [])
333
- return {"role": role, "selected": chosen, "participants": participant_ids,
334
- "selection_reason": selection_reason,
335
- "candidates": rows,
336
- "fallback": [x["agent_id"] for x in rows
337
- if x["agent_id"] not in active_ids]}
338
-
339
-
340
- def pick_reviewer(agents, impl, ttype, stats=None, exclude=(),
341
- dead_upstreams=(), difficulty="default"):
342
- """评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
343
- 评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
344
- 绝不把回退伪装成跨厂商。exclude 用于剔除运行时已知死候选(实现步走查证伪
345
- 的 quota/403 死链),与 pick / pick_switch_candidate 的 exclude 同语义。
346
- dead_upstreams 进一步剔除「与死者同上游」的候选——配额通常按网关/账号烧刻,
347
- 同上游 = 同配额桶,403 权限拒绝也不能换壳重试。评审者又是单点,撞死链会
348
- 白烧一轮并误报「评审器故障」,故宁可少一个候选也不选已知同上游的。"""
349
- stats = stats or history.agent_stats()
350
- excluded = set(exclude)
351
- dead_sets = [set(d) for d in (dead_upstreams or ()) if d]
352
-
353
- def _alive(a):
354
- if a["id"] in excluded:
355
- return False
356
- if not dead_sets:
357
- return True
358
- ups = agent_upstreams(a.get("id"), difficulty=difficulty,
359
- task_type=ttype, role="review")
360
- return not (ups and any(ups & d for d in dead_sets))
361
-
362
- impl_kind = impl.get("kind")
363
- real = [a for a in agents
364
- if a.get("mode") == "real" and a["id"] != impl["id"] and _alive(a)]
365
- cross = [a for a in real if a.get("kind") != impl_kind]
366
- if cross:
367
- best = max(cross, key=lambda a: score(a, "review", ttype, stats)[0])
368
- _, reason = score(best, "review", ttype, stats)
369
- return best, "跨厂商评审(%s ≠ %s):%s" % (best.get("kind"), impl_kind, reason)
370
- if real:
371
- best = max(real, key=lambda a: score(a, "review", ttype, stats)[0])
372
- _, reason = score(best, "review", ttype, stats)
373
- return best, ("(无跨厂商智能体可用,回退同厂商 %s 评审——建议启用其他厂商的 CLI)"
374
- % impl_kind) + reason
375
- mb = next((a for a in agents if a["id"] == "mock-b"), None)
376
- if mb and impl.get("mode") != "mock":
377
- return mb, "(无第二个真实智能体,用 mock 评审)"
378
- return impl, "(自评:仅有实现者一个智能体可用)"
379
-
380
-
381
- def pick_critics(agents, ttype, stats=None, impl=None):
382
- """自动内容评审组选综合分前两名(排除作者本人)。
383
-
384
- 旧逻辑让全部已启用 CLI 串行参与每章和全局评审;一条慢/失效链就会把
385
- 任务拖长数分钟。两名保留交叉判断并封顶默认调用数;手工模式不走这里。
386
- """
387
- stats = stats or history.agent_stats()
388
- real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
389
- if real:
390
- ranked = sorted(real, key=lambda a: score(a, "review", ttype, stats)[0], reverse=True)
391
- real = ranked[:AUTO_CRITIC_LIMIT]
392
- note = "按成功率与延迟选择前 %d 名评审(共 %d 名候选)" % (len(real), len(ranked))
393
- if impl and impl.get("mode") == "real" \
394
- and all(a.get("kind") == impl.get("kind") for a in real):
395
- note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
396
- return real, note
397
- mocks = [a for a in agents if a.get("mode") == "mock"] or agents[:2]
398
- return mocks, "(无真实智能体,用内置 mock 演示)"
1
+ # -*- coding: utf-8 -*-
2
+ """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+
9
+ from . import dispatch, history, upstream
10
+
11
+ # 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
12
+ CAPABILITY = {
13
+ "codex": 84, "claude": 84, "opencode": 74, "qwen": 72,
14
+ "aider": 70, "openclaw": 60, "generic": 55, "mock": 10,
15
+ }
16
+
17
+ MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
18
+ AUTO_CRITIC_LIMIT = 2 # 自动内容评审只取综合分前两名;手工模式仍尊重完整名单
19
+
20
+
21
+ def _task_type(ttype):
22
+ """兼容旧调用的字符串与新任务规格对象。"""
23
+ if isinstance(ttype, dict):
24
+ return str(ttype.get("type") or ttype.get("dimension") or "direct")
25
+ return str(ttype or "direct")
26
+
27
+
28
+ def _binding_bonus(agent_id, dispatch_mode=False):
29
+ """绑定链可用性加分/减分:解析出的调用链有备胎(≥2 条)+8;单条 = 单点
30
+ 无降级空间 +0;解析为空 -25。2026-09-16 实测:静态能力基线让配额烧干的
31
+ codex 永远压过健康备用 CLI,绑定空的 CLI 更是连用户配置的模型都没用上
32
+ ——先按「能不能按配置跑起来」校准。2026-09-22 续修:单条链(含空链+
33
+ 仅 provider_id 的旧数据)拿过 +8 是信号失真——2026-09-22 智谱余额清零
34
+ 实测「绑定链可用」的 claude/kimi 全是单点,无一家有第二口气。返回
35
+ (分值, 理由文本)。"""
36
+ try:
37
+ from . import modelhub
38
+ pref = modelhub._binding_for(agent_id)
39
+ configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
40
+ if not configured:
41
+ return (0.0, "") if dispatch_mode else (-25.0, ",绑定链为空:相关步骤将判失败(-25.0)")
42
+ b = modelhub.resolve_binding(agent_id)
43
+ n = len((b or {}).get("call_chain") or [])
44
+ if n >= 2:
45
+ return 8.0, ",绑定链可用(+8.0)"
46
+ if n == 1:
47
+ return 0.0, ",绑定单点无备胎(+0)"
48
+ return -25.0, ",绑定链为空:相关步骤将判失败(-25.0)"
49
+ except Exception:
50
+ return 0.0, ""
51
+
52
+
53
+ def _history_bonus(stats, agent_id, ttype):
54
+ s = (stats.get(agent_id) or {}).get(ttype) or {}
55
+ if not s.get("runs"):
56
+ return 0.0
57
+ runs = s["runs"]
58
+ win_rate = s["wins"] / runs
59
+ # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
60
+ # 偶发失败把智能体埋了)。扣分项封顶 -24,0/3 全败净 -21(含 +3 经验分)、
61
+ # 0/6 净 -18:常挂的 CLI 必须排到无历史的新面孔之后(2026-09-17 前败率不
62
+ # 扣分,0/3 还拿 +6 经验分,比没跑过还高)。
63
+ loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
64
+ return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
65
+
66
+
67
+ def _online_bonus(agent, role, ttype):
68
+ """近期真实运行信号:成功率、P95 延迟和均价只作软加减分。"""
69
+ try:
70
+ from . import usage
71
+ metrics = usage.routing_stats(task_type=ttype, role=role,
72
+ agent=agent.get("id") or "")
73
+ samples = int(metrics.get("samples") or 0)
74
+ if not samples:
75
+ return 0.0, ""
76
+ rate = float(metrics.get("success_rate") or 0.0)
77
+ success_score = max(-8.0, min(8.0, (rate - 0.75) * 24.0))
78
+ p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
79
+ # 旧上限 -6 抵不过 CLI 静态能力 10 分差,P95 两三分钟的模型仍会
80
+ # 压过十几秒的健康模型。加大惩罚,让真实延迟足以改变自动选择。
81
+ latency_score = -min(18.0, max(0.0, (p95 - 30.0) / 7.0))
82
+ cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
83
+ cost_score = -min(4.0, max(0.0, (cost - 0.01) / 0.01))
84
+ total = round(success_score + latency_score + cost_score, 1)
85
+ success_samples = int(metrics.get("success_samples") or samples)
86
+ reason = (",在线 %d/%d 验收成功(%+.1f),P95 %.1fs(%+.1f),均价 $%.4f(%+.1f)"
87
+ % (int(metrics.get("successes") or 0), success_samples,
88
+ success_score, p95, latency_score, cost, cost_score))
89
+ # 标准要求回退层出现在候选理由里(成本与时延预算第 5 条):
90
+ # exact/task-role/task/global,让"分数来自哪层样本"可核对。
91
+ reason += ",样本层 %s" % (metrics.get("fallback") or "global")
92
+ return total, reason
93
+ except Exception:
94
+ return 0.0, ""
95
+
96
+
97
+ def score(agent, role, ttype, stats=None):
98
+ """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
99
+ quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
100
+ (封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
101
+ stats = stats or {}
102
+ ttype = _task_type(ttype)
103
+ base = CAPABILITY.get(agent.get("kind"), 60)
104
+ use_dispatch = bool(agent.get("_dispatch_task_type") or
105
+ agent.get("dispatch_enabled"))
106
+ bb, btxt = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
107
+ hb = _history_bonus(stats, agent.get("id"), ttype)
108
+ online, online_txt = _online_bonus(agent, role, ttype)
109
+ # 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
110
+ # 才启用能力亲和度,避免旧插件/测试调用被新权重悄然改变。
111
+ affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
112
+ if use_dispatch else (0.0, "兼容模式"))
113
+ total = base + bb + hb + affinity + online
114
+ hs = (stats.get(agent.get("id")) or {}).get(ttype)
115
+ htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
116
+ quota_txt = ""
117
+ quota = int(agent.get("quota_tokens_per_hour") or 0)
118
+ if quota > 0:
119
+ try:
120
+ from . import usage
121
+ used = usage.agent_tokens_recent(agent.get("id"), hours=1)
122
+ except Exception:
123
+ used = 0
124
+ if used > 0:
125
+ ratio = min(1.0, used / float(quota))
126
+ penalty = round(-45.0 * ratio, 1)
127
+ if penalty:
128
+ total += penalty
129
+ quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
130
+ return total, "能力基线 %d,%s%s%s%s%s,总分 %s" % (
131
+ base, affinity_txt, btxt, htxt, online_txt, quota_txt, round(total, 1))
132
+
133
+
134
+ def pick(agents, role, ttype, stats=None, exclude=()):
135
+ """按分选出最优智能体。返回 (agent, 理由) 或 (None, "")。"""
136
+ stats = stats or history.agent_stats()
137
+ best, best_reason = None, ""
138
+ for a in agents:
139
+ if a["id"] in exclude:
140
+ continue
141
+ total, reason = score(a, role, ttype, stats)
142
+ if best is None or total > best[0]:
143
+ best, best_reason = (total, a), reason
144
+ if best is None:
145
+ return None, ""
146
+ return best[1], best_reason
147
+
148
+
149
+ # 空链 CLI 的真实上游在各自本机配置里(注册表里的 provider_id 是 UI 残留,
150
+ # 2026-09-22 实测 qwencode 注册表指 prov-36 本机却指公司网关)。正则探针
151
+ # 只为换将时识别「同上游」,读不到就算未知——未知不参与剔除,宁白试不误杀。
152
+ _LOCAL_ENDPOINT_PROBES = (
153
+ ("kimi-code", "~/.kimi-code/config.toml", r'baseUrl\s*=\s*"([^"]+)"'),
154
+ ("claude-code", "~/.claude/settings.json", r'"ANTHROPIC_BASE_URL"\s*:\s*"([^"]+)"'),
155
+ ("qwencode", "~/.qwen/settings.json", r'"OPENAI_BASE_URL"\s*:\s*"([^"]+)"'),
156
+ ("opencode", "~/.config/opencode/opencode.json", r'"baseURL"\s*:\s*"([^"]+)"'),
157
+ ("opencode", "~/.config/opencode/opencode.jsonc", r'"baseURL"\s*:\s*"([^"]+)"'),
158
+ ("codex-cli", "~/.codex/config.toml", r'base_url\s*=\s*"([^"]+)"'),
159
+ )
160
+
161
+ _PI_SETTINGS_PATH = "~/.pi/agent/settings.json"
162
+ _PI_MODELS_PATH = "~/.pi/agent/models.json"
163
+
164
+
165
+ def _pi_provider_hosts(selected_name):
166
+ """按选中名取 pi 供应商块的 host:port(先 models.json,再 settings.json 兜底)。
167
+
168
+ 老配置可能把 providers 直接写进 settings.json,两件都认;只认选中的那块。"""
169
+ hosts = set()
170
+ if not selected_name:
171
+ return hosts
172
+ for rel in (_PI_MODELS_PATH, _PI_SETTINGS_PATH):
173
+ try:
174
+ with open(os.path.expanduser(rel), "r", encoding="utf-8") as fh:
175
+ cfg = json.load(fh)
176
+ except (OSError, ValueError, TypeError):
177
+ continue
178
+ if not isinstance(cfg, dict):
179
+ continue
180
+ block = (cfg.get("providers") or {}).get(selected_name)
181
+ if not isinstance(block, dict):
182
+ continue
183
+ h = _host_of(block.get("baseUrl") or block.get("baseURL") or
184
+ block.get("base_url"))
185
+ if h:
186
+ hosts.add(h)
187
+ break # 命中即定:models.json 优先,别让另一件的旧块参与判断
188
+ return hosts
189
+
190
+
191
+ def _host_of(url):
192
+ """上游归一:路径、认证信息和默认端口不影响同一上游身份。"""
193
+ return upstream.normalize_upstream(url)
194
+
195
+
196
+ def agent_upstreams(agent_id, difficulty="default", task_type="", role=""):
197
+ """候选实际会用到的上游集合(host:port 归一)。显式链优先;空绑定按
198
+ 运行期自动推荐链判断,再回落到 CLI 本机配置;都拿不到返回空集 = 未知。"""
199
+ ups = set()
200
+ try:
201
+ from . import modelhub
202
+ pref = modelhub._binding_for(agent_id)
203
+ provs = {p.get("id"): p for p in modelhub.providers()}
204
+
205
+ def _add_chain(chain):
206
+ for item in chain or ():
207
+ pid = (item.get("provider_id") or "").strip()
208
+ provider = item.get("provider") or provs.get(pid) or {}
209
+ h = _host_of(provider.get("base_url"))
210
+ if h:
211
+ ups.add(h)
212
+
213
+ configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
214
+ # 保留原始显式链中的 endpoint,即使某条已禁用/失效;同一个死上游仍
215
+ # 不值得在另一个 CLI 上立刻再撞一次。
216
+ if configured:
217
+ chain = modelhub._binding_chain(pref)
218
+ _add_chain(chain)
219
+ resolved = modelhub.resolve_binding(agent_id, difficulty) or {}
220
+ _add_chain(resolved.get("call_chain"))
221
+ else:
222
+ # bind_agent 在空绑定时会自动推荐供应商链。换将选路必须观察同一
223
+ # 生效链,否则“空绑定”的多个 CLI 可能被排成不同候选却共用一个 403 网关。
224
+ recommended = modelhub.recommend_binding(
225
+ agent_id, difficulty, task_type=task_type, role=role) or {}
226
+ _add_chain(recommended.get("call_chain"))
227
+ except Exception:
228
+ pass
229
+ if not ups:
230
+ if agent_id == "pi":
231
+ # Pi 的选择位在 settings.json(defaultProvider),而 endpoint 在**同目录
232
+ # models.json** 的 providers.<选中名>.baseUrl(pi 的 getModelsPath(),
233
+ # CodeBee 托管与用户手工配置都落在那儿)——不能只扫所有 baseUrl,
234
+ # 那会把未选中的 provider 也误算成当前上游。
235
+ try:
236
+ with open(os.path.expanduser(_PI_SETTINGS_PATH), "r",
237
+ encoding="utf-8") as fh:
238
+ settings = json.load(fh)
239
+ selected_name = str((settings.get("defaultProvider")
240
+ if isinstance(settings, dict) else "") or "")
241
+ ups.update(_pi_provider_hosts(selected_name))
242
+ except (OSError, ValueError, TypeError):
243
+ pass
244
+ if not ups:
245
+ for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
246
+ if aid != agent_id:
247
+ continue
248
+ try:
249
+ with open(os.path.expanduser(path), "r", encoding="utf-8",
250
+ errors="ignore") as fh:
251
+ txt = fh.read()
252
+ except OSError:
253
+ continue
254
+ for m in re.finditer(pat, txt):
255
+ h = _host_of(m.group(1))
256
+ if h:
257
+ ups.add(h)
258
+ return ups
259
+
260
+
261
+ def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
262
+ dead_upstreams=(), difficulty="default",
263
+ blocked_upstreams=()):
264
+ """换将选将。配额类死亡背景下:与死者同上游的候选依次让位,直到找到
265
+ 异上游/上游未知的候选(2026-09-22 实案:kimi 与 claude 同骑智谱,0.1 分
266
+ 之差把异上游 qwencode 压在下面,换将=换壳不换命)。异上游耗尽后同上游
267
+ 候选捡回分数最高者——聊胜于无。403 明确拒绝的上游始终阻断,不捡回重试。"""
268
+ exclude = set(exclude)
269
+ blocked_sets = [set(d) for d in (blocked_upstreams or ()) if d]
270
+ best, reason = pick(agents, role, ttype, stats, exclude=exclude)
271
+ if best is None or (not dead_upstreams and not blocked_sets):
272
+ return best, reason
273
+ deferred = []
274
+ while best is not None:
275
+ ups = agent_upstreams(best.get("id"), difficulty=difficulty,
276
+ task_type=ttype, role=role)
277
+ if ups and any(ups & blocked for blocked in blocked_sets):
278
+ # 明确的 403 权限拒绝:同一上游没有换模型/换 CLI 再试的价值,
279
+ # 即使异上游候选耗尽也不把它捡回来(与可换账号的配额错误不同)。
280
+ exclude.add(best.get("id"))
281
+ best, reason = pick(agents, role, ttype, stats, exclude=exclude)
282
+ continue
283
+ if not (ups and any(ups & dead for dead in dead_upstreams)):
284
+ break # 异上游或上游未知:就用它
285
+ deferred.append((best, reason))
286
+ best, reason = pick(agents, role, ttype, stats, exclude=exclude | {
287
+ x.get("id") for x, _ in deferred})
288
+ if best is None and deferred:
289
+ best, reason = deferred[0] # 异上游全灭:同上游里挑最高的顶上
290
+ elif deferred:
291
+ reason = ("%s(%s 与死者同上游,延后让位异上游候选)"
292
+ % (reason, deferred[-1][0].get("id")))
293
+ return best, reason
294
+
295
+
296
+ def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
297
+ participants=(), selection_reason=""):
298
+ """生成可审计的候选排序,并可用实际选路覆盖评分预选结果。"""
299
+ if stats is None:
300
+ stats = history.agent_stats()
301
+ # 与 pick 保持同一候选池;绑定/健康扣分仍由 score 和 modelhub 负责,
302
+ # 诊断不能悄悄排除实际可能被选中的 mock 或备用 CLI。
303
+ pool = list(agents or [])
304
+ rows = []
305
+ for index, agent in enumerate(pool):
306
+ if agent.get("id") in exclude:
307
+ continue
308
+ candidate = dict(agent)
309
+ if isinstance(task_spec, dict):
310
+ candidate["_dispatch_task_type"] = task_spec.get("type") or "direct"
311
+ candidate["_dispatch_role"] = role
312
+ total, reason = score(candidate, role, task_spec, stats)
313
+ rows.append({"agent_id": agent.get("id") or "",
314
+ "label": agent.get("label") or agent.get("id") or "",
315
+ "kind": agent.get("kind") or "generic",
316
+ "score": round(total, 1), "reason": reason,
317
+ "order": index})
318
+ rows.sort(key=lambda x: (-x["score"], x["order"]))
319
+ selected_id = (selected or {}).get("id") if isinstance(selected, dict) else ""
320
+ if selected_id and not any(x["agent_id"] == selected_id for x in rows):
321
+ rows.append({"agent_id": selected_id,
322
+ "label": selected.get("label") or selected_id,
323
+ "kind": selected.get("kind") or "builtin",
324
+ "score": 0.0, "reason": selection_reason or "实际选路",
325
+ "order": len(rows)})
326
+ chosen = selected_id or (rows[0]["agent_id"] if rows else "")
327
+ participant_ids = []
328
+ for agent in participants or ():
329
+ agent_id = agent.get("id") if isinstance(agent, dict) else str(agent or "")
330
+ if agent_id and agent_id not in participant_ids:
331
+ participant_ids.append(agent_id)
332
+ active_ids = participant_ids or ([chosen] if chosen else [])
333
+ return {"role": role, "selected": chosen, "participants": participant_ids,
334
+ "selection_reason": selection_reason,
335
+ "candidates": rows,
336
+ "fallback": [x["agent_id"] for x in rows
337
+ if x["agent_id"] not in active_ids]}
338
+
339
+
340
+ def pick_reviewer(agents, impl, ttype, stats=None, exclude=(),
341
+ dead_upstreams=(), difficulty="default"):
342
+ """评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
343
+ 评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
344
+ 绝不把回退伪装成跨厂商。exclude 用于剔除运行时已知死候选(实现步走查证伪
345
+ 的 quota/403 死链),与 pick / pick_switch_candidate 的 exclude 同语义。
346
+ dead_upstreams 进一步剔除「与死者同上游」的候选——配额通常按网关/账号烧刻,
347
+ 同上游 = 同配额桶,403 权限拒绝也不能换壳重试。评审者又是单点,撞死链会
348
+ 白烧一轮并误报「评审器故障」,故宁可少一个候选也不选已知同上游的。"""
349
+ stats = stats or history.agent_stats()
350
+ excluded = set(exclude)
351
+ dead_sets = [set(d) for d in (dead_upstreams or ()) if d]
352
+
353
+ def _alive(a):
354
+ if a["id"] in excluded:
355
+ return False
356
+ if not dead_sets:
357
+ return True
358
+ ups = agent_upstreams(a.get("id"), difficulty=difficulty,
359
+ task_type=ttype, role="review")
360
+ return not (ups and any(ups & d for d in dead_sets))
361
+
362
+ impl_kind = impl.get("kind")
363
+ real = [a for a in agents
364
+ if a.get("mode") == "real" and a["id"] != impl["id"] and _alive(a)]
365
+ cross = [a for a in real if a.get("kind") != impl_kind]
366
+ if cross:
367
+ best = max(cross, key=lambda a: score(a, "review", ttype, stats)[0])
368
+ _, reason = score(best, "review", ttype, stats)
369
+ return best, "跨厂商评审(%s ≠ %s):%s" % (best.get("kind"), impl_kind, reason)
370
+ if real:
371
+ best = max(real, key=lambda a: score(a, "review", ttype, stats)[0])
372
+ _, reason = score(best, "review", ttype, stats)
373
+ return best, ("(无跨厂商智能体可用,回退同厂商 %s 评审——建议启用其他厂商的 CLI)"
374
+ % impl_kind) + reason
375
+ mb = next((a for a in agents if a["id"] == "mock-b"), None)
376
+ if mb and impl.get("mode") != "mock":
377
+ return mb, "(无第二个真实智能体,用 mock 评审)"
378
+ return impl, "(自评:仅有实现者一个智能体可用)"
379
+
380
+
381
+ def pick_critics(agents, ttype, stats=None, impl=None):
382
+ """自动内容评审组选综合分前两名(排除作者本人)。
383
+
384
+ 旧逻辑让全部已启用 CLI 串行参与每章和全局评审;一条慢/失效链就会把
385
+ 任务拖长数分钟。两名保留交叉判断并封顶默认调用数;手工模式不走这里。
386
+ """
387
+ stats = stats or history.agent_stats()
388
+ real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
389
+ if real:
390
+ ranked = sorted(real, key=lambda a: score(a, "review", ttype, stats)[0], reverse=True)
391
+ real = ranked[:AUTO_CRITIC_LIMIT]
392
+ note = "按成功率与延迟选择前 %d 名评审(共 %d 名候选)" % (len(real), len(ranked))
393
+ if impl and impl.get("mode") == "real" \
394
+ and all(a.get("kind") == impl.get("kind") for a in real):
395
+ note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
396
+ return real, note
397
+ mocks = [a for a in agents if a.get("mode") == "mock"] or agents[:2]
398
+ return mocks, "(无真实智能体,用内置 mock 演示)"