codebee 0.1.50 → 0.1.52
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 +13 -0
- package/README.md +3 -4
- package/app/core/automation.py +7 -1
- package/app/core/builtin_agent.py +172 -97
- package/app/core/catalog.py +21 -2
- package/app/core/jobs.py +170 -42
- package/app/core/knowledge.py +4 -1
- package/app/core/manager.py +24 -7
- package/app/core/market.py +27 -1
- package/app/core/pipeline.py +58 -12
- package/app/core/planner.py +14 -5
- package/app/core/prefs.py +25 -20
- package/app/core/router.py +189 -104
- package/app/core/runner.py +37 -3
- package/app/core/settings.py +4 -3
- package/app/core/skills.py +41 -13
- package/app/core/store.py +11 -0
- package/app/core/zentao.py +126 -17
- package/app/main.py +29 -6
- package/app/ui/app.js +13324 -13171
- package/app/ui/i18n.js +63 -4
- package/app/ui/index.html +11 -11
- package/app/ui/style.css +22 -1
- package/package.json +1 -1
package/app/core/router.py
CHANGED
|
@@ -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
|
-
"""
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 演示)"
|
package/app/core/runner.py
CHANGED
|
@@ -903,6 +903,33 @@ def _rate_limited(err):
|
|
|
903
903
|
return any(k in err for k in _RATE_LIMIT)
|
|
904
904
|
|
|
905
905
|
|
|
906
|
+
# 审计日志错误行探针:只认强信号,避免把回显提示词里的普通词当错误
|
|
907
|
+
_LOG_ERR_HINT = re.compile(
|
|
908
|
+
r"(429|402|rate[_ ]?limit|quota|余额|欠费|insufficient|billing|arrears"
|
|
909
|
+
r"|ENOTFOUND|ECONNREFUSED|failed to run prompt|api_error|overloaded)",
|
|
910
|
+
re.I)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _augment_error_from_log(out, log_path, limit=300):
|
|
914
|
+
"""stdout/stderr 尾段没有错误信号时,从流式审计日志里补最后一条错误行。
|
|
915
|
+
真错误有时只走流式管道(kimi 撞 429 时捕获输出只剩启动横幅),不补的
|
|
916
|
+
话路由层与用户都只看到「退出码 1;stderr/stdout: kimi version 2.0.2」。"""
|
|
917
|
+
if not log_path or not out.get("error"):
|
|
918
|
+
return
|
|
919
|
+
try:
|
|
920
|
+
with open(log_path, "rb") as fh:
|
|
921
|
+
fh.seek(0, 2)
|
|
922
|
+
size = fh.tell()
|
|
923
|
+
fh.seek(max(0, size - 8192))
|
|
924
|
+
blob = fh.read().decode("utf-8", "ignore")
|
|
925
|
+
except OSError:
|
|
926
|
+
return
|
|
927
|
+
hits = [ln.strip() for ln in blob.splitlines() if _LOG_ERR_HINT.search(ln)]
|
|
928
|
+
if not hits:
|
|
929
|
+
return
|
|
930
|
+
out["error"] = (out["error"] + ";日志错误行: " + hits[-1][:limit])[-800:]
|
|
931
|
+
|
|
932
|
+
|
|
906
933
|
def _grace_wait(cancel_event, seconds, log_path=None, why=""):
|
|
907
934
|
"""链尾限流宽限等待:小片睡眠随时响应取消。返回 False=已取消(别再重试)。"""
|
|
908
935
|
if log_path:
|
|
@@ -1249,9 +1276,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1249
1276
|
elif kind == "claude":
|
|
1250
1277
|
stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
|
|
1251
1278
|
else:
|
|
1252
|
-
# 数据驱动:catalog orch.stall_timeout_s——给 kimi
|
|
1253
|
-
# CLI
|
|
1254
|
-
#
|
|
1279
|
+
# 数据驱动:catalog orch.stall_timeout_s——给 kimi/qwen 这类评审用
|
|
1280
|
+
# CLI 配置后,静默挂死即杀(qwen 900s:2026-09-22 MCP 收尾死锁案);
|
|
1281
|
+
# mimo 等结束才一次性输出的仍留 0(开了必误杀),靠总超时兜底
|
|
1255
1282
|
try:
|
|
1256
1283
|
stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
|
|
1257
1284
|
except Exception:
|
|
@@ -1370,6 +1397,13 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1370
1397
|
pass
|
|
1371
1398
|
except Exception:
|
|
1372
1399
|
pass
|
|
1400
|
+
if not (_quota_error(out["error"]) or _transient_error(out["error"])
|
|
1401
|
+
or _rate_limited(out["error"])):
|
|
1402
|
+
# 尾段清洗后没有错误信号而流式审计日志里有:补日志错误行。
|
|
1403
|
+
# 2026-09-22 实测 kimi 撞 429 时 stderr/stdout 只剩启动横幅
|
|
1404
|
+
# (真错误只走了流式管道),路由层看不到配额关键词,换将
|
|
1405
|
+
# 判死因全靠猜。
|
|
1406
|
+
_augment_error_from_log(out, log_path)
|
|
1373
1407
|
out["error_code"] = _classify_failure(res, kind=kind)
|
|
1374
1408
|
break
|
|
1375
1409
|
if kind == "codex":
|
package/app/core/settings.py
CHANGED
|
@@ -33,9 +33,10 @@ DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
|
|
|
33
33
|
# 桌宠形象白名单(与 app/pet.py 的 SKINS 对齐;这里不 import pet 模块,避免
|
|
34
34
|
# core 反向依赖 app 根目录脚本)
|
|
35
35
|
PET_SKINS = ("plush", "robot")
|
|
36
|
-
# 并发保护上限
|
|
37
|
-
#
|
|
38
|
-
|
|
36
|
+
# 并发保护上限 24:编排任务有空位即直接启动,满载回滚 queued 排队自动补跑,
|
|
37
|
+
# 不秒判失败;同任务单飞守卫在 jobs 层。该上限保护的是本机资源(CLI 子进程
|
|
38
|
+
# 数),与各 API 厂商的限流无关;direct 对话走 jobs 轻量池不占此额度。
|
|
39
|
+
MIN_WORKERS, MAX_WORKERS = 1, 24
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
def builtin_workdir():
|
package/app/core/skills.py
CHANGED
|
@@ -295,16 +295,35 @@ def list_lessons(scope=None, only_enabled=False, category=None):
|
|
|
295
295
|
if (x.get("category") or LESSON_UNCATEGORIZED) == category]
|
|
296
296
|
if only_enabled:
|
|
297
297
|
items = [x for x in items if x.get("enabled", True)]
|
|
298
|
-
|
|
298
|
+
# karma 感知排序(UI 列表用;注入排序在 relevance_top 不走这里):
|
|
299
|
+
# 有效教训(won-lost 高)排最前——经验库页首屏即「真实帮上忙的」,
|
|
300
|
+
# 其次按注入热度;失守多的自然沉底(不隐藏——降权不删除)。
|
|
301
|
+
items.sort(key=lambda x: (-(int(x.get("won") or 0) - int(x.get("lost") or 0)),
|
|
302
|
+
-int(x.get("hits") or 0), -int(x.get("seen") or 1),
|
|
299
303
|
x.get("created_at") or ""))
|
|
300
304
|
return items
|
|
301
305
|
|
|
302
306
|
|
|
307
|
+
def _title_containment(a, b):
|
|
308
|
+
"""标题近似度:字符 bigram 包含度(交集/较短者)。与 knowledge._title_sim
|
|
309
|
+
同款度量(追加后缀形态 Jaccard 漏、包含度=1.0),教训/知识去重口径一致。"""
|
|
310
|
+
ga, gb = _text_bigrams(a), _text_bigrams(b)
|
|
311
|
+
if not ga or not gb:
|
|
312
|
+
return 0.0
|
|
313
|
+
return len(ga & gb) / float(min(len(ga), len(gb)))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
_LESSON_NEAR_DUP = 0.8 # 同 scope 教训标题包含度 ≥0.8 视为同一条(近似题合并)
|
|
317
|
+
|
|
318
|
+
|
|
303
319
|
def upsert_lesson(scope, title, content, source="", category=None, dim=None):
|
|
304
320
|
"""写入/合并一条教训:同 scope 同标题视为同一条(seen+1,内容取新的)。
|
|
305
321
|
|
|
306
322
|
category 为闭集枚举之一(见 LESSON_CATEGORIES);输入非法时退到 dim 关键词映射,
|
|
307
323
|
仍归不出则落「未分类」。id 仍只按 scope+标题哈希,故老教训再沉淀会合并而非分裂。
|
|
324
|
+
近似题合并(与知识库同款防膨胀):自动复盘每次 done 运行都跑,「节奏拖沓」与
|
|
325
|
+
「节奏拖沓问题」会各占一条;同 scope 标题包含度 ≥0.8 也视为同一条,合并语义
|
|
326
|
+
与精确同题一致。<4 字符短题只认精确(bigram 噪声大)。
|
|
308
327
|
"""
|
|
309
328
|
title = str(title or "").strip()[:60]
|
|
310
329
|
content = str(content or "").strip()[:1200]
|
|
@@ -315,18 +334,27 @@ def upsert_lesson(scope, title, content, source="", category=None, dim=None):
|
|
|
315
334
|
with _LOCK:
|
|
316
335
|
data = _load()
|
|
317
336
|
items = data.setdefault("lessons", [])
|
|
318
|
-
for
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
337
|
+
hit = next((x for x in items if x.get("id") == lid), None)
|
|
338
|
+
if hit is None and len(title) >= 4:
|
|
339
|
+
for x in items:
|
|
340
|
+
if x.get("scope") != scope:
|
|
341
|
+
continue
|
|
342
|
+
xt = str(x.get("title") or "")
|
|
343
|
+
if len(xt) >= 4 and _title_containment(title, xt) >= _LESSON_NEAR_DUP:
|
|
344
|
+
hit = x
|
|
345
|
+
break
|
|
346
|
+
if hit is not None:
|
|
347
|
+
it = hit
|
|
348
|
+
it["content"] = content
|
|
349
|
+
it["seen"] = int(it.get("seen") or 1) + 1
|
|
350
|
+
it["updated_at"] = _now()
|
|
351
|
+
# 合并时不降级已有分类:除非本次归到了明确类别,或该条原本没有分类
|
|
352
|
+
if cat != LESSON_UNCATEGORIZED or not it.get("category"):
|
|
353
|
+
it["category"] = cat
|
|
354
|
+
if source:
|
|
355
|
+
it["source"] = source
|
|
356
|
+
_save(data)
|
|
357
|
+
return it
|
|
330
358
|
it = {"id": lid, "scope": scope, "title": title, "content": content,
|
|
331
359
|
"source": source, "hits": 0, "seen": 1, "enabled": True,
|
|
332
360
|
"category": cat,
|
package/app/core/store.py
CHANGED
|
@@ -135,6 +135,11 @@ def create_task(payload):
|
|
|
135
135
|
task["direct_model"] = _text(payload.get("direct_model"), "direct_model")[:160]
|
|
136
136
|
if task["direct_model"] and not task["direct_provider_id"]:
|
|
137
137
|
raise ValueError("指定对话模型时必须同时指定厂商")
|
|
138
|
+
# 手动指定执行 CLI(2026-09-23):优先级高于模型绑定——运行时走该 CLI
|
|
139
|
+
# 的会话链而非内置智能体。存 id(如 codex-cli),可用性在运行时校验
|
|
140
|
+
da = _text(payload.get("direct_agent"), "direct_agent")[:80]
|
|
141
|
+
if da:
|
|
142
|
+
task["direct_agent"] = da
|
|
138
143
|
# 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
|
|
139
144
|
from . import gitmod
|
|
140
145
|
git_rev = _text(payload.get("git_rev"), "git_rev")
|
|
@@ -1511,6 +1516,12 @@ def update_task_params(task_id, patch):
|
|
|
1511
1516
|
task["direct_provider_id"] = pv
|
|
1512
1517
|
task["direct_model"] = mv
|
|
1513
1518
|
changed = True
|
|
1519
|
+
# 执行 CLI 可改/可清(清空 = 回内置智能体或模型绑定);与模型绑定
|
|
1520
|
+
# 允许共存,运行时 CLI 优先
|
|
1521
|
+
av = str((patch or {}).get("direct_agent") or "").strip()[:80]
|
|
1522
|
+
if task.get("direct_agent") != av and (av or task.get("direct_agent")):
|
|
1523
|
+
task["direct_agent"] = av
|
|
1524
|
+
changed = True
|
|
1514
1525
|
if changed:
|
|
1515
1526
|
_save_json(paths.TASKS_DIR / (task_id + ".json"), task)
|
|
1516
1527
|
bump_state()
|