codebee 0.1.61 → 0.1.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +490 -496
- package/LICENSE +21 -21
- package/README.md +486 -486
- package/app/core/aiflavor.py +197 -197
- package/app/core/attachments.py +532 -532
- package/app/core/automation.py +758 -758
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/board.py +209 -209
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1191 -1191
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +186 -186
- package/app/core/covergen.py +356 -356
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +173 -173
- package/app/core/dispatch_log.py +113 -113
- package/app/core/env_scrub.py +85 -85
- package/app/core/error_codes.py +64 -64
- package/app/core/errorlog.py +137 -137
- package/app/core/flows.py +355 -355
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +341 -341
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +156 -156
- package/app/core/jobs.py +1022 -1022
- package/app/core/manager.py +1996 -1996
- package/app/core/market.py +362 -362
- package/app/core/market_remote.py +66 -66
- package/app/core/mocks.py +64 -64
- package/app/core/modelhub.py +3266 -3266
- package/app/core/notify.py +106 -106
- package/app/core/paihang.py +109 -109
- package/app/core/paths.py +2 -2
- package/app/core/pipeline.py +4426 -4384
- package/app/core/planner.py +27 -27
- package/app/core/portguard.py +111 -111
- package/app/core/preview.py +293 -293
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +367 -367
- package/app/core/publish/browser.py +533 -533
- package/app/core/publish/fanqie.py +128 -128
- package/app/core/publish/flow.py +406 -406
- package/app/core/publish/ledger.py +198 -198
- package/app/core/publish/manager.py +590 -590
- package/app/core/publish/qimao.py +141 -141
- package/app/core/publish/ws.py +139 -139
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +124 -124
- package/app/core/router.py +307 -307
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +149 -149
- package/app/core/settings_schema.py +262 -262
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +106 -95
- package/app/core/skillpacks/fanqie-novel.md +80 -80
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/volumes.py +355 -355
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2090 -2090
- package/app/main.py +2826 -2826
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +13977 -13977
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +2711 -2711
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +1 -1
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +6125 -6125
- package/bin/tutti.js +147 -147
- package/package.json +41 -41
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
package/app/core/router.py
CHANGED
|
@@ -1,307 +1,307 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
import re
|
|
7
|
-
|
|
8
|
-
from . import dispatch, history
|
|
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
|
-
|
|
16
|
-
MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
|
|
17
|
-
AUTO_CRITIC_LIMIT = 2 # 自动内容评审只取综合分前两名;手工模式仍尊重完整名单
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _task_type(ttype):
|
|
21
|
-
"""兼容旧调用的字符串与新任务规格对象。"""
|
|
22
|
-
if isinstance(ttype, dict):
|
|
23
|
-
return str(ttype.get("type") or ttype.get("dimension") or "direct")
|
|
24
|
-
return str(ttype or "direct")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _binding_bonus(agent_id, dispatch_mode=False):
|
|
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
|
|
37
|
-
pref = modelhub._binding_for(agent_id)
|
|
38
|
-
configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
|
|
39
|
-
if not configured:
|
|
40
|
-
return (0.0, "") if dispatch_mode else (-25.0, ",绑定链为空:相关步骤将判失败(-25.0)")
|
|
41
|
-
b = modelhub.resolve_binding(agent_id)
|
|
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
|
-
|
|
52
|
-
def _history_bonus(stats, agent_id, ttype):
|
|
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)
|
|
62
|
-
return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _online_bonus(agent, role, ttype):
|
|
66
|
-
"""近期真实运行信号:成功率、P95 延迟和均价只作软加减分。"""
|
|
67
|
-
try:
|
|
68
|
-
from . import usage
|
|
69
|
-
metrics = usage.routing_stats(task_type=ttype, role=role,
|
|
70
|
-
agent=agent.get("id") or "")
|
|
71
|
-
samples = int(metrics.get("samples") or 0)
|
|
72
|
-
if not samples:
|
|
73
|
-
return 0.0, ""
|
|
74
|
-
rate = float(metrics.get("success_rate") or 0.0)
|
|
75
|
-
success_score = max(-8.0, min(8.0, (rate - 0.75) * 24.0))
|
|
76
|
-
p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
|
|
77
|
-
# 旧上限 -6 抵不过 CLI 静态能力 10 分差,P95 两三分钟的模型仍会
|
|
78
|
-
# 压过十几秒的健康模型。加大惩罚,让真实延迟足以改变自动选择。
|
|
79
|
-
latency_score = -min(18.0, max(0.0, (p95 - 30.0) / 7.0))
|
|
80
|
-
cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
|
|
81
|
-
cost_score = -min(4.0, max(0.0, (cost - 0.01) / 0.01))
|
|
82
|
-
total = round(success_score + latency_score + cost_score, 1)
|
|
83
|
-
success_samples = int(metrics.get("success_samples") or samples)
|
|
84
|
-
reason = (",在线 %d/%d 验收成功(%+.1f),P95 %.1fs(%+.1f),均价 $%.4f(%+.1f)"
|
|
85
|
-
% (int(metrics.get("successes") or 0), success_samples,
|
|
86
|
-
success_score, p95, latency_score, cost, cost_score))
|
|
87
|
-
return total, reason
|
|
88
|
-
except Exception:
|
|
89
|
-
return 0.0, ""
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def score(agent, role, ttype, stats=None):
|
|
93
|
-
"""返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
|
|
94
|
-
quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
|
|
95
|
-
(封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
|
|
96
|
-
stats = stats or {}
|
|
97
|
-
ttype = _task_type(ttype)
|
|
98
|
-
base = CAPABILITY.get(agent.get("kind"), 60)
|
|
99
|
-
use_dispatch = bool(agent.get("_dispatch_task_type") or
|
|
100
|
-
agent.get("dispatch_enabled"))
|
|
101
|
-
bb, btxt = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
|
|
102
|
-
hb = _history_bonus(stats, agent.get("id"), ttype)
|
|
103
|
-
online, online_txt = _online_bonus(agent, role, ttype)
|
|
104
|
-
# 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
|
|
105
|
-
# 才启用能力亲和度,避免旧插件/测试调用被新权重悄然改变。
|
|
106
|
-
affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
|
|
107
|
-
if use_dispatch else (0.0, "兼容模式"))
|
|
108
|
-
total = base + bb + hb + affinity + online
|
|
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)
|
|
125
|
-
return total, "能力基线 %d,%s%s%s%s%s,总分 %s" % (
|
|
126
|
-
base, affinity_txt, btxt, htxt, online_txt, quota_txt, round(total, 1))
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def pick(agents, role, ttype, stats=None, exclude=()):
|
|
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, ""
|
|
141
|
-
return best[1], best_reason
|
|
142
|
-
|
|
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
|
-
|
|
223
|
-
def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
|
|
224
|
-
participants=(), selection_reason=""):
|
|
225
|
-
"""生成可审计的候选排序,并可用实际选路覆盖评分预选结果。"""
|
|
226
|
-
if stats is None:
|
|
227
|
-
stats = history.agent_stats()
|
|
228
|
-
# 与 pick 保持同一候选池;绑定/健康扣分仍由 score 和 modelhub 负责,
|
|
229
|
-
# 诊断不能悄悄排除实际可能被选中的 mock 或备用 CLI。
|
|
230
|
-
pool = list(agents or [])
|
|
231
|
-
rows = []
|
|
232
|
-
for index, agent in enumerate(pool):
|
|
233
|
-
if agent.get("id") in exclude:
|
|
234
|
-
continue
|
|
235
|
-
candidate = dict(agent)
|
|
236
|
-
if isinstance(task_spec, dict):
|
|
237
|
-
candidate["_dispatch_task_type"] = task_spec.get("type") or "direct"
|
|
238
|
-
candidate["_dispatch_role"] = role
|
|
239
|
-
total, reason = score(candidate, role, task_spec, stats)
|
|
240
|
-
rows.append({"agent_id": agent.get("id") or "",
|
|
241
|
-
"label": agent.get("label") or agent.get("id") or "",
|
|
242
|
-
"kind": agent.get("kind") or "generic",
|
|
243
|
-
"score": round(total, 1), "reason": reason,
|
|
244
|
-
"order": index})
|
|
245
|
-
rows.sort(key=lambda x: (-x["score"], x["order"]))
|
|
246
|
-
selected_id = (selected or {}).get("id") if isinstance(selected, dict) else ""
|
|
247
|
-
if selected_id and not any(x["agent_id"] == selected_id for x in rows):
|
|
248
|
-
rows.append({"agent_id": selected_id,
|
|
249
|
-
"label": selected.get("label") or selected_id,
|
|
250
|
-
"kind": selected.get("kind") or "builtin",
|
|
251
|
-
"score": 0.0, "reason": selection_reason or "实际选路",
|
|
252
|
-
"order": len(rows)})
|
|
253
|
-
chosen = selected_id or (rows[0]["agent_id"] if rows else "")
|
|
254
|
-
participant_ids = []
|
|
255
|
-
for agent in participants or ():
|
|
256
|
-
agent_id = agent.get("id") if isinstance(agent, dict) else str(agent or "")
|
|
257
|
-
if agent_id and agent_id not in participant_ids:
|
|
258
|
-
participant_ids.append(agent_id)
|
|
259
|
-
active_ids = participant_ids or ([chosen] if chosen else [])
|
|
260
|
-
return {"role": role, "selected": chosen, "participants": participant_ids,
|
|
261
|
-
"selection_reason": selection_reason,
|
|
262
|
-
"candidates": rows,
|
|
263
|
-
"fallback": [x["agent_id"] for x in rows
|
|
264
|
-
if x["agent_id"] not in active_ids]}
|
|
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
|
-
|
|
290
|
-
def pick_critics(agents, ttype, stats=None, impl=None):
|
|
291
|
-
"""自动内容评审组选综合分前两名(排除作者本人)。
|
|
292
|
-
|
|
293
|
-
旧逻辑让全部已启用 CLI 串行参与每章和全局评审;一条慢/失效链就会把
|
|
294
|
-
任务拖长数分钟。两名保留交叉判断并封顶默认调用数;手工模式不走这里。
|
|
295
|
-
"""
|
|
296
|
-
stats = stats or history.agent_stats()
|
|
297
|
-
real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
|
|
298
|
-
if real:
|
|
299
|
-
ranked = sorted(real, key=lambda a: score(a, "review", ttype, stats)[0], reverse=True)
|
|
300
|
-
real = ranked[:AUTO_CRITIC_LIMIT]
|
|
301
|
-
note = "按成功率与延迟选择前 %d 名评审(共 %d 名候选)" % (len(real), len(ranked))
|
|
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 演示)"
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from . import dispatch, history
|
|
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
|
+
|
|
16
|
+
MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
|
|
17
|
+
AUTO_CRITIC_LIMIT = 2 # 自动内容评审只取综合分前两名;手工模式仍尊重完整名单
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _task_type(ttype):
|
|
21
|
+
"""兼容旧调用的字符串与新任务规格对象。"""
|
|
22
|
+
if isinstance(ttype, dict):
|
|
23
|
+
return str(ttype.get("type") or ttype.get("dimension") or "direct")
|
|
24
|
+
return str(ttype or "direct")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _binding_bonus(agent_id, dispatch_mode=False):
|
|
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
|
|
37
|
+
pref = modelhub._binding_for(agent_id)
|
|
38
|
+
configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
|
|
39
|
+
if not configured:
|
|
40
|
+
return (0.0, "") if dispatch_mode else (-25.0, ",绑定链为空:相关步骤将判失败(-25.0)")
|
|
41
|
+
b = modelhub.resolve_binding(agent_id)
|
|
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
|
+
|
|
52
|
+
def _history_bonus(stats, agent_id, ttype):
|
|
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)
|
|
62
|
+
return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _online_bonus(agent, role, ttype):
|
|
66
|
+
"""近期真实运行信号:成功率、P95 延迟和均价只作软加减分。"""
|
|
67
|
+
try:
|
|
68
|
+
from . import usage
|
|
69
|
+
metrics = usage.routing_stats(task_type=ttype, role=role,
|
|
70
|
+
agent=agent.get("id") or "")
|
|
71
|
+
samples = int(metrics.get("samples") or 0)
|
|
72
|
+
if not samples:
|
|
73
|
+
return 0.0, ""
|
|
74
|
+
rate = float(metrics.get("success_rate") or 0.0)
|
|
75
|
+
success_score = max(-8.0, min(8.0, (rate - 0.75) * 24.0))
|
|
76
|
+
p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
|
|
77
|
+
# 旧上限 -6 抵不过 CLI 静态能力 10 分差,P95 两三分钟的模型仍会
|
|
78
|
+
# 压过十几秒的健康模型。加大惩罚,让真实延迟足以改变自动选择。
|
|
79
|
+
latency_score = -min(18.0, max(0.0, (p95 - 30.0) / 7.0))
|
|
80
|
+
cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
|
|
81
|
+
cost_score = -min(4.0, max(0.0, (cost - 0.01) / 0.01))
|
|
82
|
+
total = round(success_score + latency_score + cost_score, 1)
|
|
83
|
+
success_samples = int(metrics.get("success_samples") or samples)
|
|
84
|
+
reason = (",在线 %d/%d 验收成功(%+.1f),P95 %.1fs(%+.1f),均价 $%.4f(%+.1f)"
|
|
85
|
+
% (int(metrics.get("successes") or 0), success_samples,
|
|
86
|
+
success_score, p95, latency_score, cost, cost_score))
|
|
87
|
+
return total, reason
|
|
88
|
+
except Exception:
|
|
89
|
+
return 0.0, ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def score(agent, role, ttype, stats=None):
|
|
93
|
+
"""返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
|
|
94
|
+
quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
|
|
95
|
+
(封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
|
|
96
|
+
stats = stats or {}
|
|
97
|
+
ttype = _task_type(ttype)
|
|
98
|
+
base = CAPABILITY.get(agent.get("kind"), 60)
|
|
99
|
+
use_dispatch = bool(agent.get("_dispatch_task_type") or
|
|
100
|
+
agent.get("dispatch_enabled"))
|
|
101
|
+
bb, btxt = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
|
|
102
|
+
hb = _history_bonus(stats, agent.get("id"), ttype)
|
|
103
|
+
online, online_txt = _online_bonus(agent, role, ttype)
|
|
104
|
+
# 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
|
|
105
|
+
# 才启用能力亲和度,避免旧插件/测试调用被新权重悄然改变。
|
|
106
|
+
affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
|
|
107
|
+
if use_dispatch else (0.0, "兼容模式"))
|
|
108
|
+
total = base + bb + hb + affinity + online
|
|
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)
|
|
125
|
+
return total, "能力基线 %d,%s%s%s%s%s,总分 %s" % (
|
|
126
|
+
base, affinity_txt, btxt, htxt, online_txt, quota_txt, round(total, 1))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pick(agents, role, ttype, stats=None, exclude=()):
|
|
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, ""
|
|
141
|
+
return best[1], best_reason
|
|
142
|
+
|
|
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
|
+
|
|
223
|
+
def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
|
|
224
|
+
participants=(), selection_reason=""):
|
|
225
|
+
"""生成可审计的候选排序,并可用实际选路覆盖评分预选结果。"""
|
|
226
|
+
if stats is None:
|
|
227
|
+
stats = history.agent_stats()
|
|
228
|
+
# 与 pick 保持同一候选池;绑定/健康扣分仍由 score 和 modelhub 负责,
|
|
229
|
+
# 诊断不能悄悄排除实际可能被选中的 mock 或备用 CLI。
|
|
230
|
+
pool = list(agents or [])
|
|
231
|
+
rows = []
|
|
232
|
+
for index, agent in enumerate(pool):
|
|
233
|
+
if agent.get("id") in exclude:
|
|
234
|
+
continue
|
|
235
|
+
candidate = dict(agent)
|
|
236
|
+
if isinstance(task_spec, dict):
|
|
237
|
+
candidate["_dispatch_task_type"] = task_spec.get("type") or "direct"
|
|
238
|
+
candidate["_dispatch_role"] = role
|
|
239
|
+
total, reason = score(candidate, role, task_spec, stats)
|
|
240
|
+
rows.append({"agent_id": agent.get("id") or "",
|
|
241
|
+
"label": agent.get("label") or agent.get("id") or "",
|
|
242
|
+
"kind": agent.get("kind") or "generic",
|
|
243
|
+
"score": round(total, 1), "reason": reason,
|
|
244
|
+
"order": index})
|
|
245
|
+
rows.sort(key=lambda x: (-x["score"], x["order"]))
|
|
246
|
+
selected_id = (selected or {}).get("id") if isinstance(selected, dict) else ""
|
|
247
|
+
if selected_id and not any(x["agent_id"] == selected_id for x in rows):
|
|
248
|
+
rows.append({"agent_id": selected_id,
|
|
249
|
+
"label": selected.get("label") or selected_id,
|
|
250
|
+
"kind": selected.get("kind") or "builtin",
|
|
251
|
+
"score": 0.0, "reason": selection_reason or "实际选路",
|
|
252
|
+
"order": len(rows)})
|
|
253
|
+
chosen = selected_id or (rows[0]["agent_id"] if rows else "")
|
|
254
|
+
participant_ids = []
|
|
255
|
+
for agent in participants or ():
|
|
256
|
+
agent_id = agent.get("id") if isinstance(agent, dict) else str(agent or "")
|
|
257
|
+
if agent_id and agent_id not in participant_ids:
|
|
258
|
+
participant_ids.append(agent_id)
|
|
259
|
+
active_ids = participant_ids or ([chosen] if chosen else [])
|
|
260
|
+
return {"role": role, "selected": chosen, "participants": participant_ids,
|
|
261
|
+
"selection_reason": selection_reason,
|
|
262
|
+
"candidates": rows,
|
|
263
|
+
"fallback": [x["agent_id"] for x in rows
|
|
264
|
+
if x["agent_id"] not in active_ids]}
|
|
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
|
+
|
|
290
|
+
def pick_critics(agents, ttype, stats=None, impl=None):
|
|
291
|
+
"""自动内容评审组选综合分前两名(排除作者本人)。
|
|
292
|
+
|
|
293
|
+
旧逻辑让全部已启用 CLI 串行参与每章和全局评审;一条慢/失效链就会把
|
|
294
|
+
任务拖长数分钟。两名保留交叉判断并封顶默认调用数;手工模式不走这里。
|
|
295
|
+
"""
|
|
296
|
+
stats = stats or history.agent_stats()
|
|
297
|
+
real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
|
|
298
|
+
if real:
|
|
299
|
+
ranked = sorted(real, key=lambda a: score(a, "review", ttype, stats)[0], reverse=True)
|
|
300
|
+
real = ranked[:AUTO_CRITIC_LIMIT]
|
|
301
|
+
note = "按成功率与延迟选择前 %d 名评审(共 %d 名候选)" % (len(real), len(ranked))
|
|
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 演示)"
|