codebee 0.1.65 → 0.1.67
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 +532 -513
- package/LICENSE +21 -21
- package/README.md +494 -487
- package/app/core/acceptance.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -0
- package/app/core/attachments.py +623 -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.py +930 -759
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1234 -1191
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +23 -24
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -186
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -0
- 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 +220 -65
- package/app/core/errorlog.py +137 -137
- package/app/core/evaluation.py +174 -0
- package/app/core/failure_notify.py +290 -0
- package/app/core/flows.py +401 -355
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +374 -342
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -156
- package/app/core/jobs.py +1050 -1022
- package/app/core/knowledge.py +56 -5
- package/app/core/manager.py +2122 -1996
- package/app/core/market.py +362 -362
- package/app/core/market_remote.py +190 -92
- package/app/core/mocks.py +64 -64
- package/app/core/modelhub.py +3546 -3332
- package/app/core/notify.py +106 -106
- package/app/core/operations.py +228 -0
- package/app/core/paihang.py +138 -109
- package/app/core/paths.py +2 -2
- package/app/core/pipeline.py +4808 -4494
- package/app/core/planner.py +37 -32
- 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 +539 -533
- package/app/core/publish/fanqie.py +159 -128
- package/app/core/publish/flow.py +440 -406
- package/app/core/publish/flows-fanqie-calibrated.json +17 -9
- package/app/core/publish/flows-qimao-calibrated.json +6 -2
- package/app/core/publish/ledger.py +203 -198
- package/app/core/publish/manager.py +692 -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 +129 -124
- package/app/core/revisions.py +51 -0
- package/app/core/router.py +119 -81
- package/app/core/runner.py +2065 -2042
- 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 +114 -106
- 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/skills.py +315 -73
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +230 -11
- 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/upstream.py +41 -0
- package/app/core/usage.py +114 -3
- package/app/core/volumes.py +389 -355
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2465 -2447
- package/app/main.py +2957 -2832
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +14384 -13977
- package/app/ui/artdiff.js +74 -0
- package/app/ui/board.css +20 -41
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +2766 -2711
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +7 -1
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +6179 -6125
- package/bin/tutti.js +147 -147
- package/package.json +46 -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/repeat_guard.py
CHANGED
|
@@ -1,124 +1,129 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""重复 CLI 调用检测:防止编排者用同一份 prompt 反复调同一 CLI 死循环。
|
|
3
|
-
|
|
4
|
-
设计稿:docs/migration/01-defense-patterns.md §5C。
|
|
5
|
-
参考 dsh packages/guard/repeat-tool-reminder/src/index.ts:
|
|
6
|
-
监听 tools/post-execute,按 (tool, canonical_args) 跟踪,3/5/8 阈值注入 user-message 提醒。
|
|
7
|
-
|
|
8
|
-
Tutti 的 "tool" 对应到
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
self.
|
|
56
|
-
|
|
57
|
-
self.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""重复 CLI 调用检测:防止编排者用同一份 prompt 反复调同一 CLI 死循环。
|
|
3
|
+
|
|
4
|
+
设计稿:docs/migration/01-defense-patterns.md §5C。
|
|
5
|
+
参考 dsh packages/guard/repeat-tool-reminder/src/index.ts:
|
|
6
|
+
监听 tools/post-execute,按 (tool, canonical_args) 跟踪,3/5/8 阈值注入 user-message 提醒。
|
|
7
|
+
|
|
8
|
+
Tutti 的 "tool" 对应到 step role + 执行者身份,"canonical_args" 对应到 prompt
|
|
9
|
+
模板指纹。不同 CLI 间的换将是有效的回退,不能共享同一个重复预算。步骤级调用
|
|
10
|
+
代价远高于普通工具调用,因此第 3 次提醒、第 5 次强制停止。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# 渐进阈值:第 N 次命中时给提醒;最后一次及以上时强制停止。
|
|
22
|
+
DEFAULT_THRESHOLDS = (3, 5)
|
|
23
|
+
|
|
24
|
+
REMINDER_TEMPLATE = (
|
|
25
|
+
"⚠️ 提醒:上一步使用了几乎相同的输入(已连续 {count} 次调用 {role})。"
|
|
26
|
+
"请换一种方法:读不同的文件、用更具体的指令、或明确询问用户澄清。"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
STOP_TEMPLATE = (
|
|
30
|
+
"已连续 {count} 次使用相同输入调用 {role},超过 {last_thr} 次阈值,"
|
|
31
|
+
"强制停止本 step 以防止无限循环。请用户检查输入或调整 prompt 模板。"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _prompt_fingerprint(prompt: str, identity: str = "") -> str:
|
|
36
|
+
"""prompt 指纹:用前 200 字符 + 总长度(CLI prompt 头几行通常是模板部分)。
|
|
37
|
+
|
|
38
|
+
不做严格 canonical args 比对(CLI prompt 通常很长,且 dsh 也用 JSON.stringify);
|
|
39
|
+
指纹命中说明是同一个模板路径走过多次,已经够分辨。
|
|
40
|
+
|
|
41
|
+
用 SHA-256 取前 12 字符(96 bit):48 亿分之一碰撞概率对去重足够;不追求密码学强度。
|
|
42
|
+
"""
|
|
43
|
+
head = (prompt or "")[:200]
|
|
44
|
+
return hashlib.sha256(
|
|
45
|
+
f"{identity}\0{head}|{len(prompt or '')}".encode("utf-8")).hexdigest()[:12]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RepeatGuard:
|
|
49
|
+
"""每 (run_id, step_role) 的最近执行链独立计数;指纹包含执行者身份。
|
|
50
|
+
|
|
51
|
+
计数仅统计相同 key 连续出现;新 prompt 会重置。
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, thresholds=DEFAULT_THRESHOLDS, *, max_history=50):
|
|
55
|
+
self.thresholds = tuple(sorted(set(thresholds)))
|
|
56
|
+
self.max_history = max_history
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
# chain[(run_id, role)] = [(ts, fingerprint)]
|
|
59
|
+
self._chains: dict = {}
|
|
60
|
+
|
|
61
|
+
def check(self, run_id: str, role: str, prompt: str, *, identity: str = "") -> dict:
|
|
62
|
+
"""检查并更新 (run_id, role) 的连续计数。
|
|
63
|
+
|
|
64
|
+
identity 标识实际执行 CLI/agent。相同 prompt 换到其他执行者属于回退,
|
|
65
|
+
会从新的连续链计数;同一执行者重复相同调用仍按阈值停止。
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
{"count": int, "reminder": str | None, "should_stop": bool}
|
|
69
|
+
"""
|
|
70
|
+
fp = _prompt_fingerprint(prompt, str(identity or ""))
|
|
71
|
+
chain_key = (run_id, role)
|
|
72
|
+
with self._lock:
|
|
73
|
+
chain = self._chains.setdefault(chain_key, [])
|
|
74
|
+
chain.append((time.time(), fp))
|
|
75
|
+
# 仅统计相同 fp 连续出现
|
|
76
|
+
count = 1
|
|
77
|
+
for _, prev_fp in reversed(chain[:-1]):
|
|
78
|
+
if prev_fp == fp:
|
|
79
|
+
count += 1
|
|
80
|
+
else:
|
|
81
|
+
break
|
|
82
|
+
# 截尾避免内存膨胀
|
|
83
|
+
if len(chain) > self.max_history:
|
|
84
|
+
self._chains[chain_key] = chain[-self.max_history:]
|
|
85
|
+
|
|
86
|
+
reminder = None
|
|
87
|
+
stop = False
|
|
88
|
+
last_thr = self.thresholds[-1]
|
|
89
|
+
if count >= last_thr:
|
|
90
|
+
stop = True
|
|
91
|
+
reminder = STOP_TEMPLATE.format(count=count, role=role, last_thr=last_thr)
|
|
92
|
+
elif count in self.thresholds:
|
|
93
|
+
reminder = REMINDER_TEMPLATE.format(count=count, role=role)
|
|
94
|
+
|
|
95
|
+
if reminder:
|
|
96
|
+
log.warning("repeat-guard %s/%s count=%d stop=%s reminder=%s",
|
|
97
|
+
run_id, role, count, stop, reminder[:80])
|
|
98
|
+
return {"count": count, "reminder": reminder, "should_stop": stop}
|
|
99
|
+
|
|
100
|
+
def reset(self, run_id: str) -> int:
|
|
101
|
+
"""重置某 run_id 的所有 (run_id, *) 链。返回清除的链条数。"""
|
|
102
|
+
with self._lock:
|
|
103
|
+
cleared = 0
|
|
104
|
+
for k in list(self._chains.keys()):
|
|
105
|
+
if k[0] == run_id:
|
|
106
|
+
del self._chains[k]
|
|
107
|
+
cleared += 1
|
|
108
|
+
return cleared
|
|
109
|
+
|
|
110
|
+
def stats(self, run_id: str, role: str) -> dict:
|
|
111
|
+
"""读取 (run_id, role) 当前状态(不更新)。用于 UI 调试。"""
|
|
112
|
+
chain_key = (run_id, role)
|
|
113
|
+
with self._lock:
|
|
114
|
+
chain = self._chains.get(chain_key, [])
|
|
115
|
+
if not chain:
|
|
116
|
+
return {"chain_len": 0, "last_fp": ""}
|
|
117
|
+
# 当前连续计数
|
|
118
|
+
last_fp = chain[-1][1]
|
|
119
|
+
count = 1
|
|
120
|
+
for _, fp in reversed(chain[:-1]):
|
|
121
|
+
if fp == last_fp:
|
|
122
|
+
count += 1
|
|
123
|
+
else:
|
|
124
|
+
break
|
|
125
|
+
return {"chain_len": len(chain), "last_fp": last_fp, "count": count}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# 模块级默认实例(pipeline 接线用;run_id 入键天然隔离多任务,测试无需替换)
|
|
129
|
+
guard = RepeatGuard()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Small, content-addressed revision helpers.
|
|
3
|
+
|
|
4
|
+
The execution layer stores revision identifiers and digests instead of copying
|
|
5
|
+
skill/knowledge bodies into every run. A digest is deliberately based on the
|
|
6
|
+
semantic payload supplied by the caller, so formatting-only metadata changes do
|
|
7
|
+
not silently change the meaning of an existing run.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonical_json(value) -> str:
|
|
17
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True,
|
|
18
|
+
separators=(",", ":"), default=str)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def content_sha256(value) -> str:
|
|
22
|
+
if not isinstance(value, str):
|
|
23
|
+
value = canonical_json(value)
|
|
24
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def revision_id(prefix: str, digest: str) -> str:
|
|
28
|
+
return "%s-%s" % (str(prefix or "rev"), str(digest or "")[:16])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_revision(prefix: str, content, *, source="", created_at=None) -> dict:
|
|
32
|
+
digest = content_sha256(content)
|
|
33
|
+
return {
|
|
34
|
+
"revision_id": revision_id(prefix, digest),
|
|
35
|
+
"content_sha256": digest,
|
|
36
|
+
"source": str(source or "")[:160],
|
|
37
|
+
"created_at": created_at or time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def revision_record(prefix: str, content, *, source="", created_at=None,
|
|
42
|
+
extra=None) -> dict:
|
|
43
|
+
out = make_revision(prefix, content, source=source, created_at=created_at)
|
|
44
|
+
if isinstance(extra, dict):
|
|
45
|
+
out.update(extra)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def stable_digest(items) -> str:
|
|
50
|
+
"""Digest a list/dict of revision metadata without embedding content."""
|
|
51
|
+
return content_sha256(items)
|
package/app/core/router.py
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"""智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
8
|
|
|
9
|
-
from . import dispatch, history
|
|
9
|
+
from . import dispatch, history, upstream
|
|
10
10
|
|
|
11
11
|
# 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
|
|
12
12
|
CAPABILITY = {
|
|
@@ -57,8 +57,9 @@ def _history_bonus(stats, agent_id, ttype):
|
|
|
57
57
|
runs = s["runs"]
|
|
58
58
|
win_rate = s["wins"] / runs
|
|
59
59
|
# 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
|
|
60
|
-
#
|
|
61
|
-
#
|
|
60
|
+
# 偶发失败把智能体埋了)。扣分项封顶 -24,0/3 全败净 -21(含 +3 经验分)、
|
|
61
|
+
# 0/6 净 -18:常挂的 CLI 必须排到无历史的新面孔之后(2026-09-17 前败率不
|
|
62
|
+
# 扣分,0/3 还拿 +6 经验分,比没跑过还高)。
|
|
62
63
|
loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
|
|
63
64
|
return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
|
|
64
65
|
|
|
@@ -85,6 +86,9 @@ def _online_bonus(agent, role, ttype):
|
|
|
85
86
|
reason = (",在线 %d/%d 验收成功(%+.1f),P95 %.1fs(%+.1f),均价 $%.4f(%+.1f)"
|
|
86
87
|
% (int(metrics.get("successes") or 0), success_samples,
|
|
87
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")
|
|
88
92
|
return total, reason
|
|
89
93
|
except Exception:
|
|
90
94
|
return 0.0, ""
|
|
@@ -145,77 +149,102 @@ def pick(agents, role, ttype, stats=None, exclude=()):
|
|
|
145
149
|
# 空链 CLI 的真实上游在各自本机配置里(注册表里的 provider_id 是 UI 残留,
|
|
146
150
|
# 2026-09-22 实测 qwencode 注册表指 prov-36 本机却指公司网关)。正则探针
|
|
147
151
|
# 只为换将时识别「同上游」,读不到就算未知——未知不参与剔除,宁白试不误杀。
|
|
148
|
-
_LOCAL_ENDPOINT_PROBES = (
|
|
152
|
+
_LOCAL_ENDPOINT_PROBES = (
|
|
149
153
|
("kimi-code", "~/.kimi-code/config.toml", r'baseUrl\s*=\s*"([^"]+)"'),
|
|
150
154
|
("claude-code", "~/.claude/settings.json", r'"ANTHROPIC_BASE_URL"\s*:\s*"([^"]+)"'),
|
|
151
155
|
("qwencode", "~/.qwen/settings.json", r'"OPENAI_BASE_URL"\s*:\s*"([^"]+)"'),
|
|
152
156
|
("opencode", "~/.config/opencode/opencode.json", r'"baseURL"\s*:\s*"([^"]+)"'),
|
|
153
157
|
("opencode", "~/.config/opencode/opencode.jsonc", r'"baseURL"\s*:\s*"([^"]+)"'),
|
|
154
158
|
("codex-cli", "~/.codex/config.toml", r'base_url\s*=\s*"([^"]+)"'),
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
_PI_SETTINGS_PATH = "~/.pi/agent/settings.json"
|
|
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
|
|
158
189
|
|
|
159
190
|
|
|
160
191
|
def _host_of(url):
|
|
161
|
-
"""
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
h
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
chain
|
|
188
|
-
|
|
189
|
-
resolved
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
#
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if aid != agent_id:
|
|
218
|
-
continue
|
|
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
|
|
219
248
|
try:
|
|
220
249
|
with open(os.path.expanduser(path), "r", encoding="utf-8",
|
|
221
250
|
errors="ignore") as fh:
|
|
@@ -229,20 +258,28 @@ def agent_upstreams(agent_id, difficulty="default", task_type="", role=""):
|
|
|
229
258
|
return ups
|
|
230
259
|
|
|
231
260
|
|
|
232
|
-
def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
|
|
233
|
-
dead_upstreams=(), difficulty="default"
|
|
261
|
+
def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
|
|
262
|
+
dead_upstreams=(), difficulty="default",
|
|
263
|
+
blocked_upstreams=()):
|
|
234
264
|
"""换将选将。配额类死亡背景下:与死者同上游的候选依次让位,直到找到
|
|
235
265
|
异上游/上游未知的候选(2026-09-22 实案:kimi 与 claude 同骑智谱,0.1 分
|
|
236
266
|
之差把异上游 qwencode 压在下面,换将=换壳不换命)。异上游耗尽后同上游
|
|
237
|
-
候选捡回分数最高者——聊胜于无。"""
|
|
267
|
+
候选捡回分数最高者——聊胜于无。403 明确拒绝的上游始终阻断,不捡回重试。"""
|
|
238
268
|
exclude = set(exclude)
|
|
269
|
+
blocked_sets = [set(d) for d in (blocked_upstreams or ()) if d]
|
|
239
270
|
best, reason = pick(agents, role, ttype, stats, exclude=exclude)
|
|
240
|
-
if best is None or not dead_upstreams:
|
|
271
|
+
if best is None or (not dead_upstreams and not blocked_sets):
|
|
241
272
|
return best, reason
|
|
242
|
-
deferred = []
|
|
243
|
-
while best is not None:
|
|
244
|
-
ups = agent_upstreams(best.get("id"), difficulty=difficulty,
|
|
245
|
-
task_type=ttype, role=role)
|
|
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
|
|
246
283
|
if not (ups and any(ups & dead for dead in dead_upstreams)):
|
|
247
284
|
break # 异上游或上游未知:就用它
|
|
248
285
|
deferred.append((best, reason))
|
|
@@ -301,14 +338,14 @@ def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
|
|
|
301
338
|
|
|
302
339
|
|
|
303
340
|
def pick_reviewer(agents, impl, ttype, stats=None, exclude=(),
|
|
304
|
-
dead_upstreams=()):
|
|
341
|
+
dead_upstreams=(), difficulty="default"):
|
|
305
342
|
"""评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
|
|
306
343
|
评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
|
|
307
344
|
绝不把回退伪装成跨厂商。exclude 用于剔除运行时已知死候选(实现步走查证伪
|
|
308
|
-
的 quota
|
|
345
|
+
的 quota/403 死链),与 pick / pick_switch_candidate 的 exclude 同语义。
|
|
309
346
|
dead_upstreams 进一步剔除「与死者同上游」的候选——配额通常按网关/账号烧刻,
|
|
310
|
-
同上游 =
|
|
311
|
-
|
|
347
|
+
同上游 = 同配额桶,403 权限拒绝也不能换壳重试。评审者又是单点,撞死链会
|
|
348
|
+
白烧一轮并误报「评审器故障」,故宁可少一个候选也不选已知同上游的。"""
|
|
312
349
|
stats = stats or history.agent_stats()
|
|
313
350
|
excluded = set(exclude)
|
|
314
351
|
dead_sets = [set(d) for d in (dead_upstreams or ()) if d]
|
|
@@ -318,7 +355,8 @@ def pick_reviewer(agents, impl, ttype, stats=None, exclude=(),
|
|
|
318
355
|
return False
|
|
319
356
|
if not dead_sets:
|
|
320
357
|
return True
|
|
321
|
-
ups = agent_upstreams(a.get("id")
|
|
358
|
+
ups = agent_upstreams(a.get("id"), difficulty=difficulty,
|
|
359
|
+
task_type=ttype, role="review")
|
|
322
360
|
return not (ups and any(ups & d for d in dead_sets))
|
|
323
361
|
|
|
324
362
|
impl_kind = impl.get("kind")
|