codebee 0.1.49 → 0.1.51

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.
@@ -0,0 +1,293 @@
1
+ # -*- coding: utf-8 -*-
2
+ """运行预览:把任务工作目录里的网页成品挂成「可实时查看」的页面。
3
+
4
+ 借鉴对话式编程产品(右栏「运行 / index.html / style.css / script.js」页签):
5
+ 代码任务跑完,用户要的不是翻文件,而是**直接看到东西跑起来**。这里提供两件事:
6
+
7
+ 1. list_app(run_id):从本任务的成品文件里挑出可预览的应用——入口 HTML +
8
+ 同目录的代码文件(按 html→css→js 排序),供前端渲染页签条。
9
+ 2. serve(run_id, rel):只读挂载工作目录里的静态资源,供 iframe / 新窗口直接
10
+ 打开(<img>/<link>/<script> 这些子资源请求带不了自定义请求头)。
11
+
12
+ **访问令牌走路径段**(/preview/<run>/<令牌>/<文件>):子资源是浏览器按文档
13
+ URL 相对解析的,查询串不会被继承——令牌只能放路径里才覆盖得到 style.css /
14
+ script.js。鉴权本身直接复用 remote.request_authed(与 /api/* 同一把尺子,
15
+ 含反向代理下"带转发头的 loopback 算远程"的判断),所以本地免令牌、公网必须
16
+ 带令牌,两条路都不需要新写一套判断。
17
+
18
+ 安全边界(这是把一个任意目录交给浏览器渲染,必须写清楚):
19
+ - 只认静态扩展名白名单,单文件体积上限;路径 resolve 后必须落在工作目录内。
20
+ - 工作目录下的服务端可执行物(.py/.php/.rb/.sh…)不在白名单,不会被预览。
21
+ - HTML 响应注入 <base>(相对资源在子路径下也解析得对)+ 一段极小的补丁:
22
+ 页面里的 fetch/XHR 指向 /api/ 时自动补上访问令牌,让「对话生成的示例应用」
23
+ 能真的连通后端。补丁只加一个请求头;写接口该 423/401 的照旧。
24
+ - 前端把预览 iframe 挂在同一来源下、不加 sandbox:预览要**像真的在跑**——生成的
25
+ 示例应用大量用 localStorage,sandbox 去掉 same-origin 会让它当场抛 SecurityError,
26
+ 而 allow-same-origin 的 sandbox 等于没加(页面可自行摘掉)。这与本地 dev server
27
+ 的信任模型一致:跑的是你自己的代码、在你自己的机器上。代价是预览页能读到
28
+ 本会话的 localStorage 令牌,故「预览」页签只在用户主动点开时才加载(不预跑)。
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ from pathlib import Path
35
+
36
+ from . import store
37
+
38
+ # 静态资源白名单:只挂能安全交给浏览器渲染的扩展名。刻意不含 .py/.php/.rb/.sh
39
+ # 等工作目录里可能出现的服务端可执行物——预览是只读静态托管,不是网站服务器。
40
+ PREVIEW_EXT = {
41
+ ".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map",
42
+ ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif",
43
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
44
+ ".mp3", ".wav", ".ogg", ".mp4", ".webm",
45
+ ".txt", ".md", ".csv", ".xml", ".wasm", ".yml", ".yaml", ".toml",
46
+ }
47
+
48
+ # 页签条里列出的扩展名(比可挂载范围窄):图片/字体是资源不是「文件」,
49
+ # 给它们开页签只是噪音。
50
+ TAB_EXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".svg",
51
+ ".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml", ".map"}
52
+
53
+ _HTML_EXT = {".html", ".htm"}
54
+
55
+ # 页签排序权重:入口 → 样式 → 脚本 → 数据 → 其它
56
+ _TAB_ORDER = {".html": 0, ".htm": 0, ".css": 1, ".js": 2, ".mjs": 2, ".cjs": 2,
57
+ ".json": 3, ".map": 4, ".svg": 5, ".md": 6, ".txt": 6, ".csv": 6,
58
+ ".xml": 6, ".yml": 6, ".yaml": 6, ".toml": 6}
59
+
60
+ BASE = "/preview" # 路由前缀,与 main.py 的 do_GET 分发约定
61
+ MAX_FILE = 4 << 20 # 单文件上限:超过不挂(大文件走「成果」页签下载)
62
+ MAX_TABS = 12 # 页签上限:防病态目录撑爆页签条
63
+ MAX_LIST = 400 # 参与挑选的成品文件上限
64
+
65
+ _MIME = {
66
+ ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
67
+ ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
68
+ ".mjs": "text/javascript; charset=utf-8", ".cjs": "text/javascript; charset=utf-8",
69
+ ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8",
70
+ ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
71
+ ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
72
+ ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
73
+ ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
74
+ ".otf": "font/otf", ".eot": "application/vnd.ms-fontobject",
75
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
76
+ ".mp4": "video/mp4", ".webm": "video/webm",
77
+ ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
78
+ ".csv": "text/plain; charset=utf-8", ".xml": "text/xml; charset=utf-8",
79
+ ".wasm": "application/wasm",
80
+ ".yml": "text/plain; charset=utf-8", ".yaml": "text/plain; charset=utf-8",
81
+ ".toml": "text/plain; charset=utf-8",
82
+ }
83
+
84
+ _TXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map", ".svg",
85
+ ".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml"}
86
+
87
+ _HEAD_RE = re.compile(r"<head\b[^>]*>", re.I)
88
+ _HTML_RE = re.compile(r"<html\b[^>]*>", re.I)
89
+
90
+
91
+ def _ext(name):
92
+ return Path(str(name or "")).suffix.lower()
93
+
94
+
95
+ def safe_rel(rel):
96
+ """归一化工作目录内相对路径。返回 None 表示越界/非法。"""
97
+ rel = str(rel or "").replace("\\", "/").lstrip("/")
98
+ if not rel:
99
+ return None
100
+ parts = [p for p in rel.split("/") if p not in ("", ".")]
101
+ if not parts or any(p == ".." for p in parts):
102
+ return None
103
+ # 隐藏目录(.git/.zcode/.claude…)是工具过程文件,不是成品,一律不给挂
104
+ if any(p.startswith(".") for p in parts):
105
+ return None
106
+ return "/".join(parts)
107
+
108
+
109
+ def resolve(run_id, rel):
110
+ """把 rel 解析到运行工作目录内的真实文件。返回 (Path|None, 错误文本)。
111
+
112
+ 与 store.read_run_file 同一道守卫:resolve 之后必须仍在工作目录内,
113
+ 顺带挡掉软链接指向工作目录之外的形态。
114
+ """
115
+ wd = store.run_workdir(run_id)
116
+ if not wd:
117
+ return None, "该运行没有关联的工作目录"
118
+ safe = safe_rel(rel)
119
+ if not safe:
120
+ return None, "非法路径"
121
+ if _ext(safe) not in PREVIEW_EXT:
122
+ return None, "该类型不支持预览"
123
+ root = Path(wd).resolve()
124
+ try:
125
+ p = (root / safe).resolve()
126
+ except OSError:
127
+ return None, "非法路径"
128
+ if root != p and root not in p.parents:
129
+ return None, "非法路径"
130
+ if not p.is_file():
131
+ return None, "文件不存在"
132
+ return p, None
133
+
134
+
135
+ def _base_href(run_id, tok, rel):
136
+ """入口 HTML 的 <base>:挂在 /preview/<run>/<令牌>/<入口目录>/ 上。
137
+
138
+ 令牌放在路径里是为了让 style.css / ./js/a.js / ../x.css 这些相对子资源
139
+ 自动继承它(查询串不会被子资源继承);这个绝对路径也让相对资源在
140
+ 子目录入口(web/index.html)下解析得对。
141
+ """
142
+ d = rel.rsplit("/", 1)[0] if "/" in rel else ""
143
+ return "%s/%s/%s/%s" % (BASE, run_id, tok, (d + "/") if d else "")
144
+
145
+
146
+ def _patch_token(tok):
147
+ """注入页面脚本:预览页里的 fetch/XHR 打 /api/ 时自动补访问令牌。
148
+
149
+ 生成的示例应用常直接 fetch("/api/..."),而子资源与脚本发起的请求带不了
150
+ 自定义请求头——不补的话预览里一切接口调用都 401,用户会以为是自己代码写
151
+ 错了。补丁只加一个请求头;写接口该 423/401 的照旧,不放行任何服务端能力。
152
+ """
153
+ tk = json.dumps(str(tok or ""))
154
+ return (
155
+ "<script>(function(){var TK=%s;if(!TK)return;try{"
156
+ "var isApi=function(u){u=String(u||'');return u.indexOf('/api/')===0"
157
+ "||u.indexOf('api/')===0;};"
158
+ "var _f=window.fetch;if(_f){window.fetch=function(i,o){o=o||{};"
159
+ "var u=(typeof i==='string')?i:((i&&i.url)||'');"
160
+ "if(isApi(u)){try{o.headers=new Headers(o.headers||{});"
161
+ "o.headers.set('X-CodeBee-Token',TK);}catch(e){}}"
162
+ "return _f.call(this,i,o);};}"
163
+ "var _o=XMLHttpRequest.prototype.open;"
164
+ "XMLHttpRequest.prototype.open=function(m,u){"
165
+ "this.__cbApi=isApi(u);return _o.apply(this,arguments);};"
166
+ "var _s=XMLHttpRequest.prototype.send;"
167
+ "XMLHttpRequest.prototype.send=function(b){"
168
+ "if(this.__cbApi){try{this.setRequestHeader('X-CodeBee-Token',TK);}"
169
+ "catch(e){}}return _s.apply(this,arguments);};"
170
+ "}catch(e){}})();</script>" % tk
171
+ )
172
+
173
+
174
+ def _inject(html, run_id, tok, rel):
175
+ """把 <base> + 令牌补丁插到入口 HTML 的最前面(head 内最优先)。"""
176
+ inject = ('<base href="%s">' % _base_href(run_id, tok, rel)) + _patch_token(tok)
177
+ m = _HEAD_RE.search(html)
178
+ if m:
179
+ return html[:m.end()] + inject + html[m.end():]
180
+ m = _HTML_RE.search(html)
181
+ if m:
182
+ return html[:m.end()] + inject + html[m.end():]
183
+ return inject + html
184
+
185
+
186
+ def _decode(data):
187
+ for enc in ("utf-8", "gbk"):
188
+ try:
189
+ return data.decode(enc)
190
+ except UnicodeDecodeError:
191
+ continue
192
+ return data.decode("utf-8", "replace")
193
+
194
+
195
+ def serve(run_id, rel, tok=""):
196
+ """读取一个预览资源。返回 (bytes|None, content-type, 错误文本)。"""
197
+ p, err = resolve(run_id, rel)
198
+ if err:
199
+ return None, "", err
200
+ try:
201
+ if p.stat().st_size > MAX_FILE:
202
+ return None, "", "文件过大,不支持预览"
203
+ data = p.read_bytes()
204
+ except OSError as e:
205
+ return None, "", "读取失败: %s" % e
206
+ ext = p.suffix.lower()
207
+ ctype = _MIME.get(ext, "application/octet-stream")
208
+ if ext in _HTML_EXT:
209
+ # 入口页:注入 <base> 与令牌补丁,并按 UTF-8 重编码(非 UTF-8 落 GBK
210
+ # 回退——子代理在中文 Windows 上常把文件写成 GBK)
211
+ data = _inject(_decode(data), run_id, tok, rel).encode("utf-8")
212
+ elif ext in _TXT:
213
+ data = _decode(data).encode("utf-8")
214
+ return data, ctype, None
215
+
216
+
217
+ def _pick_entry(avail):
218
+ """挑入口 HTML:优先工作目录根的 index.html,其次最浅、最新。
219
+
220
+ 先只看根目录(单页应用的标准形态),根目录没有 HTML 时才退到最浅的子目录
221
+ 入口——`web/index.html` 这类布局也该能预览。
222
+ """
223
+ htmls = [f for f in avail if _ext(f["name"]) in _HTML_EXT]
224
+ if not htmls:
225
+ return ""
226
+ roots = [f for f in htmls if "/" not in f["name"]]
227
+ cands = roots or htmls
228
+ best = None
229
+ for f in cands:
230
+ name = f["name"]
231
+ base = name.rsplit("/", 1)[-1].lower()
232
+ key = (0 if base in ("index.html", "index.htm") else 1,
233
+ name.count("/"), -int(f.get("mtime") or 0), name)
234
+ if best is None or key < best[0]:
235
+ best = (key, name)
236
+ return best[1] if best else ""
237
+
238
+
239
+ def _tab_key(f):
240
+ name = f["name"]
241
+ return (_TAB_ORDER.get(_ext(name), 9), name.count("/"),
242
+ -int(f.get("mtime") or 0), name)
243
+
244
+
245
+ def preview_path(run_id, tok):
246
+ """预览根路径(带路径令牌):前端据此拼 iframe src 与新窗口地址。
247
+
248
+ 令牌为空时落「-」占位段:远程开着时它会自然 401,本机(remote 未配令牌
249
+ 或 loopback 豁免)照常放行——路径里永远有一段,前端不用做两种拼接。
250
+ """
251
+ return "%s/%s/%s/" % (BASE, run_id, tok or "-")
252
+
253
+
254
+ def list_app(run_id, tok=""):
255
+ """本任务可预览的网页应用。返回前端渲染页签条所需的全部信息。
256
+
257
+ {"ok": False, "reason": "no_run"|"no_workdir"|"no_steps"|"no_html"} 表示没有
258
+ 可预览的东西(前端据此把「预览」页签整体隐藏,不打扰非前端任务)。
259
+ """
260
+ if not store.get_run(run_id):
261
+ return {"ok": False, "reason": "no_run"}
262
+ wd, files = store.run_artifacts(run_id, limit=MAX_LIST)
263
+ if not wd or not Path(wd).is_dir():
264
+ return {"ok": False, "reason": "no_workdir"}
265
+ # 与「成果」同一口径:一步没跑出来过的任务没有成品可言(历次都在检出等
266
+ # 前置环节失败时,工作目录里的变动是并行活动的噪音)
267
+ run = store.get_run(run_id) or {}
268
+ if not store.task_step_count(run.get("task_id") or ""):
269
+ return {"ok": False, "reason": "no_steps"}
270
+ avail = [f for f in (files or [])
271
+ if _ext(f["name"]) in PREVIEW_EXT
272
+ and int(f.get("size") or 0) <= MAX_FILE]
273
+ entry = _pick_entry(avail)
274
+ if not entry:
275
+ return {"ok": False, "reason": "no_html", "workdir": wd}
276
+ edir = entry.rsplit("/", 1)[0] if "/" in entry else ""
277
+ tabs = [f for f in avail
278
+ if _ext(f["name"]) in TAB_EXT
279
+ and (f["name"].rsplit("/", 1)[0] if "/" in f["name"] else "") == edir]
280
+ tabs.sort(key=_tab_key)
281
+ names, seen = [], set()
282
+ for f in tabs:
283
+ if f["name"] in seen:
284
+ continue
285
+ seen.add(f["name"])
286
+ names.append(f["name"])
287
+ if len(names) >= MAX_TABS:
288
+ break
289
+ # 入口一定在,且一定排第一(页签条第一格就是"这个应用",运行视图另占一格)
290
+ names = [entry] + [n for n in names if n != entry]
291
+ return {"ok": True, "entry": entry, "workdir": wd,
292
+ "base": preview_path(run_id, tok),
293
+ "files": [{"name": n, "ext": _ext(n).lstrip(".")} for n in names]}
@@ -1,15 +1,18 @@
1
- # -*- coding: utf-8 -*-
2
- """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
- from __future__ import annotations
4
-
1
+ # -*- coding: utf-8 -*-
2
+ """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+
5
8
  from . import dispatch, history
6
-
7
- # 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
8
- CAPABILITY = {
9
- "codex": 84, "claude": 84, "opencode": 74, "qwen": 72,
10
- "aider": 70, "openclaw": 60, "generic": 55, "mock": 10,
11
- }
12
-
9
+
10
+ # 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
11
+ CAPABILITY = {
12
+ "codex": 84, "claude": 84, "opencode": 74, "qwen": 72,
13
+ "aider": 70, "openclaw": 60, "generic": 55, "mock": 10,
14
+ }
15
+
13
16
  MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
14
17
  AUTO_CRITIC_LIMIT = 2 # 自动内容评审只取综合分前两名;手工模式仍尊重完整名单
15
18
 
@@ -19,35 +22,43 @@ def _task_type(ttype):
19
22
  if isinstance(ttype, dict):
20
23
  return str(ttype.get("type") or ttype.get("dimension") or "direct")
21
24
  return str(ttype or "direct")
22
-
23
-
25
+
26
+
24
27
  def _binding_bonus(agent_id, dispatch_mode=False):
25
- """绑定链可用性加分/减分:链上有可用条目 +8,解析为空 -25。2026-09-16 实测:
26
- 静态能力基线让配额烧干的 codex 永远压过健康备用 CLI,绑定空的 CLI 更是连
27
- 用户配置的模型都没用上——先按「能不能按配置跑起来」校准。2026-09-17 起
28
- 空链步骤在 pipeline 直接判失败(不再静默回落本机默认),此处只管排序。"""
29
- try:
30
- from . import modelhub
28
+ """绑定链可用性加分/减分:解析出的调用链有备胎(≥2 条)+8;单条 = 单点
29
+ 无降级空间 +0;解析为空 -25。2026-09-16 实测:静态能力基线让配额烧干的
30
+ codex 永远压过健康备用 CLI,绑定空的 CLI 更是连用户配置的模型都没用上
31
+ ——先按「能不能按配置跑起来」校准。2026-09-22 续修:单条链(含空链+
32
+ 仅 provider_id 的旧数据)拿过 +8 是信号失真——2026-09-22 智谱余额清零
33
+ 实测「绑定链可用」的 claude/kimi 全是单点,无一家有第二口气。返回
34
+ (分值, 理由文本)。"""
35
+ try:
36
+ from . import modelhub
31
37
  pref = modelhub._binding_for(agent_id)
32
38
  configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
33
39
  if not configured:
34
- return 0.0 if dispatch_mode else -25.0
40
+ return (0.0, "") if dispatch_mode else (-25.0, ",绑定链为空:相关步骤将判失败(-25.0)")
35
41
  b = modelhub.resolve_binding(agent_id)
36
- return 8.0 if (b and b.get("call_chain")) else -25.0
37
- except Exception:
38
- return 0.0
39
-
40
-
42
+ n = len((b or {}).get("call_chain") or [])
43
+ if n >= 2:
44
+ return 8.0, ",绑定链可用(+8.0)"
45
+ if n == 1:
46
+ return 0.0, ",绑定单点无备胎(+0)"
47
+ return -25.0, ",绑定链为空:相关步骤将判失败(-25.0)"
48
+ except Exception:
49
+ return 0.0, ""
50
+
51
+
41
52
  def _history_bonus(stats, agent_id, ttype):
42
- s = (stats.get(agent_id) or {}).get(ttype) or {}
43
- if not s.get("runs"):
44
- return 0.0
45
- runs = s["runs"]
46
- win_rate = s["wins"] / runs
47
- # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
48
- # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
49
- # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
50
- loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
53
+ s = (stats.get(agent_id) or {}).get(ttype) or {}
54
+ if not s.get("runs"):
55
+ return 0.0
56
+ runs = s["runs"]
57
+ win_rate = s["wins"] / runs
58
+ # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
59
+ # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
60
+ # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
61
+ loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
51
62
  return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
52
63
 
53
64
 
@@ -79,20 +90,15 @@ def _online_bonus(agent, role, ttype):
79
90
 
80
91
 
81
92
  def score(agent, role, ttype, stats=None):
82
- """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
83
- quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
84
- (封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
93
+ """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
94
+ quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
95
+ (封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
85
96
  stats = stats or {}
86
97
  ttype = _task_type(ttype)
87
- base = CAPABILITY.get(agent.get("kind"), 60)
98
+ base = CAPABILITY.get(agent.get("kind"), 60)
88
99
  use_dispatch = bool(agent.get("_dispatch_task_type") or
89
100
  agent.get("dispatch_enabled"))
90
- bb = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
91
- btxt = ""
92
- if bb > 0:
93
- btxt = ",绑定链可用(+%s)" % bb
94
- elif bb < 0:
95
- btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
101
+ bb, btxt = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
96
102
  hb = _history_bonus(stats, agent.get("id"), ttype)
97
103
  online, online_txt = _online_bonus(agent, role, ttype)
98
104
  # 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
@@ -100,41 +106,120 @@ def score(agent, role, ttype, stats=None):
100
106
  affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
101
107
  if use_dispatch else (0.0, "兼容模式"))
102
108
  total = base + bb + hb + affinity + online
103
- hs = (stats.get(agent.get("id")) or {}).get(ttype)
104
- htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
105
- quota_txt = ""
106
- quota = int(agent.get("quota_tokens_per_hour") or 0)
107
- if quota > 0:
108
- try:
109
- from . import usage
110
- used = usage.agent_tokens_recent(agent.get("id"), hours=1)
111
- except Exception:
112
- used = 0
113
- if used > 0:
114
- ratio = min(1.0, used / float(quota))
115
- penalty = round(-45.0 * ratio, 1)
116
- if penalty:
117
- total += penalty
118
- quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
109
+ hs = (stats.get(agent.get("id")) or {}).get(ttype)
110
+ htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
111
+ quota_txt = ""
112
+ quota = int(agent.get("quota_tokens_per_hour") or 0)
113
+ if quota > 0:
114
+ try:
115
+ from . import usage
116
+ used = usage.agent_tokens_recent(agent.get("id"), hours=1)
117
+ except Exception:
118
+ used = 0
119
+ if used > 0:
120
+ ratio = min(1.0, used / float(quota))
121
+ penalty = round(-45.0 * ratio, 1)
122
+ if penalty:
123
+ total += penalty
124
+ quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
119
125
  return total, "能力基线 %d,%s%s%s%s%s,总分 %s" % (
120
126
  base, affinity_txt, btxt, htxt, online_txt, quota_txt, round(total, 1))
121
-
122
-
127
+
128
+
123
129
  def pick(agents, role, ttype, stats=None, exclude=()):
124
- """按分选出最优智能体。返回 (agent, 理由) 或 (None, "")。"""
125
- stats = stats or history.agent_stats()
126
- best, best_reason = None, ""
127
- for a in agents:
128
- if a["id"] in exclude:
129
- continue
130
- total, reason = score(a, role, ttype, stats)
131
- if best is None or total > best[0]:
132
- best, best_reason = (total, a), reason
133
- if best is None:
134
- return None, ""
130
+ """按分选出最优智能体。返回 (agent, 理由) 或 (None, "")。"""
131
+ stats = stats or history.agent_stats()
132
+ best, best_reason = None, ""
133
+ for a in agents:
134
+ if a["id"] in exclude:
135
+ continue
136
+ total, reason = score(a, role, ttype, stats)
137
+ if best is None or total > best[0]:
138
+ best, best_reason = (total, a), reason
139
+ if best is None:
140
+ return None, ""
135
141
  return best[1], best_reason
136
142
 
137
143
 
144
+ # 空链 CLI 的真实上游在各自本机配置里(注册表里的 provider_id 是 UI 残留,
145
+ # 2026-09-22 实测 qwencode 注册表指 prov-36 本机却指公司网关)。正则探针
146
+ # 只为换将时识别「同上游」,读不到就算未知——未知不参与剔除,宁白试不误杀。
147
+ _LOCAL_ENDPOINT_PROBES = (
148
+ ("kimi-code", "~/.kimi-code/config.toml", r'baseUrl\s*=\s*"([^"]+)"'),
149
+ ("claude-code", "~/.claude/settings.json", r'"ANTHROPIC_BASE_URL"\s*:\s*"([^"]+)"'),
150
+ ("qwencode", "~/.qwen/settings.json", r'"OPENAI_BASE_URL"\s*:\s*"([^"]+)"'),
151
+ ("opencode", "~/.config/opencode/opencode.json", r'"baseURL"\s*:\s*"([^"]+)"'),
152
+ ("opencode", "~/.config/opencode/opencode.jsonc", r'"baseURL"\s*:\s*"([^"]+)"'),
153
+ ("codex-cli", "~/.codex/config.toml", r'base_url\s*=\s*"([^"]+)"'),
154
+ )
155
+
156
+
157
+ def _host_of(url):
158
+ """上游归一:只留 host:port。/api/anthropic 与 /v1 的路径差异不算换上游。"""
159
+ m = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)", str(url or "").strip())
160
+ return m.group(1).lower() if m else ""
161
+
162
+
163
+ def agent_upstreams(agent_id):
164
+ """候选实际会用到的上游集合(host:port 归一)。显式链 > CLI 本机配置
165
+ 端点;两者都拿不到返回空集 = 未知。"""
166
+ ups = set()
167
+ try:
168
+ from . import modelhub
169
+ pref = modelhub._binding_for(agent_id)
170
+ chain = modelhub._binding_chain(pref)
171
+ provs = {p.get("id"): p for p in modelhub.providers()}
172
+ if chain:
173
+ for item in chain:
174
+ pid = (item.get("provider_id") or "").strip()
175
+ h = _host_of((provs.get(pid) or {}).get("base_url"))
176
+ if h:
177
+ ups.add(h)
178
+ except Exception:
179
+ pass
180
+ if not ups:
181
+ for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
182
+ if aid != agent_id:
183
+ continue
184
+ try:
185
+ with open(os.path.expanduser(path), "r", encoding="utf-8",
186
+ errors="ignore") as fh:
187
+ txt = fh.read()
188
+ except OSError:
189
+ continue
190
+ for m in re.finditer(pat, txt):
191
+ h = _host_of(m.group(1))
192
+ if h:
193
+ ups.add(h)
194
+ return ups
195
+
196
+
197
+ def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
198
+ dead_upstreams=()):
199
+ """换将选将。配额类死亡背景下:与死者同上游的候选依次让位,直到找到
200
+ 异上游/上游未知的候选(2026-09-22 实案:kimi 与 claude 同骑智谱,0.1 分
201
+ 之差把异上游 qwencode 压在下面,换将=换壳不换命)。异上游耗尽后同上游
202
+ 候选捡回分数最高者——聊胜于无。"""
203
+ exclude = set(exclude)
204
+ best, reason = pick(agents, role, ttype, stats, exclude=exclude)
205
+ if best is None or not dead_upstreams:
206
+ return best, reason
207
+ deferred = []
208
+ while best is not None:
209
+ ups = agent_upstreams(best.get("id"))
210
+ if not (ups and any(ups & dead for dead in dead_upstreams)):
211
+ break # 异上游或上游未知:就用它
212
+ deferred.append((best, reason))
213
+ best, reason = pick(agents, role, ttype, stats, exclude=exclude | {
214
+ x.get("id") for x, _ in deferred})
215
+ if best is None and deferred:
216
+ best, reason = deferred[0] # 异上游全灭:同上游里挑最高的顶上
217
+ elif deferred:
218
+ reason = ("%s(%s 与死者同上游,延后让位异上游候选)"
219
+ % (reason, deferred[-1][0].get("id")))
220
+ return best, reason
221
+
222
+
138
223
  def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
139
224
  participants=(), selection_reason=""):
140
225
  """生成可审计的候选排序,并可用实际选路覆盖评分预选结果。"""
@@ -177,31 +262,31 @@ def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
177
262
  "candidates": rows,
178
263
  "fallback": [x["agent_id"] for x in rows
179
264
  if x["agent_id"] not in active_ids]}
180
-
181
-
182
- def pick_reviewer(agents, impl, ttype, stats=None):
183
- """评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
184
- 评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
185
- 绝不把回退伪装成跨厂商。"""
186
- stats = stats or history.agent_stats()
187
- impl_kind = impl.get("kind")
188
- real = [a for a in agents if a.get("mode") == "real" and a["id"] != impl["id"]]
189
- cross = [a for a in real if a.get("kind") != impl_kind]
190
- if cross:
191
- best = max(cross, key=lambda a: score(a, "review", ttype, stats)[0])
192
- _, reason = score(best, "review", ttype, stats)
193
- return best, "跨厂商评审(%s ≠ %s):%s" % (best.get("kind"), impl_kind, reason)
194
- if real:
195
- best = max(real, key=lambda a: score(a, "review", ttype, stats)[0])
196
- _, reason = score(best, "review", ttype, stats)
197
- return best, ("(无跨厂商智能体可用,回退同厂商 %s 评审——建议启用其他厂商的 CLI)"
198
- % impl_kind) + reason
199
- mb = next((a for a in agents if a["id"] == "mock-b"), None)
200
- if mb and impl.get("mode") != "mock":
201
- return mb, "(无第二个真实智能体,用 mock 评审)"
202
- return impl, "(自评:仅有实现者一个智能体可用)"
203
-
204
-
265
+
266
+
267
+ def pick_reviewer(agents, impl, ttype, stats=None):
268
+ """评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
269
+ 评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
270
+ 绝不把回退伪装成跨厂商。"""
271
+ stats = stats or history.agent_stats()
272
+ impl_kind = impl.get("kind")
273
+ real = [a for a in agents if a.get("mode") == "real" and a["id"] != impl["id"]]
274
+ cross = [a for a in real if a.get("kind") != impl_kind]
275
+ if cross:
276
+ best = max(cross, key=lambda a: score(a, "review", ttype, stats)[0])
277
+ _, reason = score(best, "review", ttype, stats)
278
+ return best, "跨厂商评审(%s ≠ %s):%s" % (best.get("kind"), impl_kind, reason)
279
+ if real:
280
+ best = max(real, key=lambda a: score(a, "review", ttype, stats)[0])
281
+ _, reason = score(best, "review", ttype, stats)
282
+ return best, ("(无跨厂商智能体可用,回退同厂商 %s 评审——建议启用其他厂商的 CLI)"
283
+ % impl_kind) + reason
284
+ mb = next((a for a in agents if a["id"] == "mock-b"), None)
285
+ if mb and impl.get("mode") != "mock":
286
+ return mb, "(无第二个真实智能体,用 mock 评审)"
287
+ return impl, "(自评:仅有实现者一个智能体可用)"
288
+
289
+
205
290
  def pick_critics(agents, ttype, stats=None, impl=None):
206
291
  """自动内容评审组选综合分前两名(排除作者本人)。
207
292
 
@@ -214,9 +299,9 @@ def pick_critics(agents, ttype, stats=None, impl=None):
214
299
  ranked = sorted(real, key=lambda a: score(a, "review", ttype, stats)[0], reverse=True)
215
300
  real = ranked[:AUTO_CRITIC_LIMIT]
216
301
  note = "按成功率与延迟选择前 %d 名评审(共 %d 名候选)" % (len(real), len(ranked))
217
- if impl and impl.get("mode") == "real" \
218
- and all(a.get("kind") == impl.get("kind") for a in real):
219
- note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
220
- return real, note
221
- mocks = [a for a in agents if a.get("mode") == "mock"] or agents[:2]
222
- return mocks, "(无真实智能体,用内置 mock 演示)"
302
+ if impl and impl.get("mode") == "real" \
303
+ and all(a.get("kind") == impl.get("kind") for a in real):
304
+ note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
305
+ return real, note
306
+ mocks = [a for a in agents if a.get("mode") == "mock"] or agents[:2]
307
+ return mocks, "(无真实智能体,用内置 mock 演示)"