codebee 0.1.0
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/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- package/package.json +39 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""手机/远程访问:访问令牌 + 多端控制权锁 + 服务地址探测。
|
|
3
|
+
|
|
4
|
+
令牌:首次启动生成,落盘 data/remote.json。本机(127.0.0.1)请求豁免,
|
|
5
|
+
非 loopback 请求必须带令牌(?token= 或 X-CodeBee-Token 头)。
|
|
6
|
+
控制权:同一时刻只有一台设备能执行写操作。空闲自动接管、45s 无心跳自动释放,
|
|
7
|
+
可强制抢夺。纯内存状态,重启即清空。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from . import paths
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------- 访问令牌
|
|
22
|
+
_TOK_LOCK = threading.Lock()
|
|
23
|
+
_TOKEN = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def token() -> str:
|
|
27
|
+
"""读取(必要时生成)访问令牌。"""
|
|
28
|
+
global _TOKEN
|
|
29
|
+
if _TOKEN:
|
|
30
|
+
return _TOKEN
|
|
31
|
+
with _TOK_LOCK:
|
|
32
|
+
if _TOKEN:
|
|
33
|
+
return _TOKEN
|
|
34
|
+
p = paths.DATA_DIR / "remote.json"
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
37
|
+
_TOKEN = str(data.get("token") or "")
|
|
38
|
+
except Exception:
|
|
39
|
+
_TOKEN = ""
|
|
40
|
+
if not _TOKEN:
|
|
41
|
+
_TOKEN = secrets.token_hex(4) # 8 位十六进制,手机好输
|
|
42
|
+
try:
|
|
43
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
p.write_text(json.dumps(
|
|
45
|
+
{"token": _TOKEN, "created": time.strftime("%Y-%m-%d %H:%M:%S")},
|
|
46
|
+
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
47
|
+
except Exception:
|
|
48
|
+
pass # 落盘失败也照常工作(内存令牌,重启更换)
|
|
49
|
+
return _TOKEN
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------- 反向代理感知
|
|
53
|
+
# Cloudflare Tunnel / frp 等都从本机回源:若不感知代理,公网请求一律被当成
|
|
54
|
+
# 127.0.0.1 而豁免令牌——等于把控制台裸奔到公网。--trusted-proxy / TUTTI_TRUST_PROXY
|
|
55
|
+
# 开启后:带转发头(CF-Connecting-IP / X-Forwarded-For)的请求视为经代理进来的
|
|
56
|
+
# 远程请求,必须带令牌;不带转发头的 loopback 仍是真本机(浏览器直接开 localhost)。
|
|
57
|
+
# 安全性:Cloudflare 边缘强制注入/覆盖 CF-Connecting-IP,外部无法伪造透传;
|
|
58
|
+
# 而能直连本机端口的攻击者伪造转发头只会让自己从"本机豁免"变成"必须带令牌"。
|
|
59
|
+
_TRUST_PROXY = False
|
|
60
|
+
PUBLIC_URL = "" # --public-url / 快速隧道:扫码弹框优先展示的公网地址
|
|
61
|
+
PUBLIC_URL_KIND = "" # quick(trycloudflare 临时,重启变)| fixed(自有域名)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def set_trusted_proxy(on: bool, public_url: str = ""):
|
|
65
|
+
global _TRUST_PROXY, PUBLIC_URL
|
|
66
|
+
_TRUST_PROXY = bool(on)
|
|
67
|
+
if public_url:
|
|
68
|
+
PUBLIC_URL = str(public_url).rstrip("/")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------- 快速隧道(trycloudflare)
|
|
72
|
+
# 安装即公网:本机装了 cloudflared 就自动开一条 Cloudflare 快速隧道,
|
|
73
|
+
# 拿到随机 *.trycloudflare.com 地址——零账号、零域名、零配置。
|
|
74
|
+
# 安全强绑定:开隧道必然同时开启 trusted-proxy(否则回源 loopback 豁免=裸奔)。
|
|
75
|
+
# 代价:每次重启地址会变(手机重新扫码即可);要固定域名见 README 公网章节。
|
|
76
|
+
_QUICK_PROC = None # cloudflared 子进程,随主服务退出
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cloudflared_exe() -> str:
|
|
80
|
+
import os
|
|
81
|
+
exe = shutil.which("cloudflared")
|
|
82
|
+
if exe:
|
|
83
|
+
return exe
|
|
84
|
+
guess = os.path.join(os.environ.get("ProgramFiles(x86)", ""), "cloudflared", "cloudflared.exe")
|
|
85
|
+
return guess if os.path.isfile(guess) else ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def has_local_creds() -> bool:
|
|
89
|
+
"""~/.cloudflared 下有隧道凭据(用户已玩过 named tunnel)。
|
|
90
|
+
|
|
91
|
+
实测(2026-09):本机存在凭据时 cloudflared 会把 quick tunnel 降级为
|
|
92
|
+
named 模式运行,随机域名恒 404;此时应走固定域名路径而不是 quick。
|
|
93
|
+
"""
|
|
94
|
+
import os
|
|
95
|
+
d = os.path.join(os.path.expanduser("~"), ".cloudflared")
|
|
96
|
+
try:
|
|
97
|
+
return any(f.endswith(".json") for f in os.listdir(d))
|
|
98
|
+
except Exception:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def start_quick_tunnel(port: int, on_url) -> bool:
|
|
103
|
+
"""后台起快速隧道,解析到随机 URL 后回调 on_url(url)(如在主线程打印+推送)。
|
|
104
|
+
|
|
105
|
+
返回 False 表示没装 cloudflared 或启动失败,调用方给出提示。
|
|
106
|
+
"""
|
|
107
|
+
global _QUICK_PROC
|
|
108
|
+
exe = cloudflared_exe()
|
|
109
|
+
if not exe or not port:
|
|
110
|
+
return False
|
|
111
|
+
import subprocess
|
|
112
|
+
flags = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0 # CREATE_NO_WINDOW
|
|
113
|
+
try:
|
|
114
|
+
_QUICK_PROC = subprocess.Popen(
|
|
115
|
+
[exe, "tunnel", "--url", "http://localhost:%d" % port, "--no-autoupdate"],
|
|
116
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
117
|
+
creationflags=flags, text=True, encoding="utf-8", errors="replace")
|
|
118
|
+
except Exception:
|
|
119
|
+
_QUICK_PROC = None
|
|
120
|
+
return False
|
|
121
|
+
import threading
|
|
122
|
+
|
|
123
|
+
def _watch():
|
|
124
|
+
global PUBLIC_URL, PUBLIC_URL_KIND
|
|
125
|
+
deadline = time.time() + 25
|
|
126
|
+
found = False
|
|
127
|
+
try:
|
|
128
|
+
for line in _QUICK_PROC.stdout:
|
|
129
|
+
if not found:
|
|
130
|
+
m = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", line or "")
|
|
131
|
+
if m:
|
|
132
|
+
set_trusted_proxy(True) # 公网暴露 ⇆ 强制反代感知,防回源豁免
|
|
133
|
+
PUBLIC_URL = m.group(0)
|
|
134
|
+
PUBLIC_URL_KIND = "quick"
|
|
135
|
+
on_url(PUBLIC_URL)
|
|
136
|
+
found = True
|
|
137
|
+
# 不 break:必须继续消费日志,否则 cloudflared 写满管道
|
|
138
|
+
# 缓冲后会整体阻塞,边缘连接注册无法完成(实测 530)
|
|
139
|
+
if time.time() > deadline and not found:
|
|
140
|
+
break
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
threading.Thread(target=_watch, daemon=True).start()
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def stop_quick_tunnel():
|
|
149
|
+
global _QUICK_PROC
|
|
150
|
+
if _QUICK_PROC:
|
|
151
|
+
try:
|
|
152
|
+
_QUICK_PROC.terminate()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
_QUICK_PROC = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def effective_ip(socket_ip: str, forwarded: str) -> str:
|
|
159
|
+
"""信任代理时从转发头取真实客户端 IP(第一跳),否则用 socket 地址。"""
|
|
160
|
+
if _TRUST_PROXY and forwarded:
|
|
161
|
+
first = forwarded.split(",")[0].strip()
|
|
162
|
+
if first:
|
|
163
|
+
return first
|
|
164
|
+
return socket_ip
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def request_authed(client_ip: str, forwarded: str, query_token: str, header_token: str) -> bool:
|
|
168
|
+
"""本机豁免;远程(或经代理回源)请求必须带正确令牌。"""
|
|
169
|
+
loopback = client_ip in ("127.0.0.1", "::1")
|
|
170
|
+
if loopback and not (_TRUST_PROXY and forwarded):
|
|
171
|
+
return True # 真本机(trust_proxy 下不带转发头的 loopback 也算)
|
|
172
|
+
tok = token()
|
|
173
|
+
if not tok:
|
|
174
|
+
return True
|
|
175
|
+
return secrets.compare_digest(str(query_token or ""), tok) or \
|
|
176
|
+
secrets.compare_digest(str(header_token or ""), tok)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------- 控制权锁
|
|
180
|
+
CTRL_TTL = 45.0 # 秒;持锁设备停止心跳后自动释放
|
|
181
|
+
_CTRL = {"client_id": "", "name": "", "expires_at": 0.0}
|
|
182
|
+
# RLock:acquire/release 持锁期间会调 control_view,它也要拿同一把锁
|
|
183
|
+
_CTRL_LOCK = threading.RLock()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _live_ctrl():
|
|
187
|
+
"""返回未过期的持锁信息;过期则视为空闲(惰性过期,无需定时线程)。"""
|
|
188
|
+
if _CTRL["client_id"] and time.time() > _CTRL["expires_at"]:
|
|
189
|
+
_CTRL.update({"client_id": "", "name": "", "expires_at": 0.0})
|
|
190
|
+
return _CTRL
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def control_view(client_id: str = "") -> dict:
|
|
194
|
+
"""对外的控制权状态:free / held(mine 标记是否是请求方自己持有)。
|
|
195
|
+
|
|
196
|
+
expires_in 按 10s 桶化:SSE 靠对比该 dict 判断"控制权是否变了",
|
|
197
|
+
精确到秒的倒计时会造成每秒一次假变化 → 全量推送。
|
|
198
|
+
"""
|
|
199
|
+
with _CTRL_LOCK:
|
|
200
|
+
c = _live_ctrl()
|
|
201
|
+
if not c["client_id"]:
|
|
202
|
+
return {"mode": "free", "mine": False}
|
|
203
|
+
return {"mode": "held", "mine": bool(client_id) and c["client_id"] == client_id,
|
|
204
|
+
"holder": c["name"] or "其他设备",
|
|
205
|
+
"expires_in": max(10, int(c["expires_at"] - time.time()) // 10 * 10)}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def acquire(client_id: str, name: str, force: bool = False):
|
|
209
|
+
"""接管控制权。已持有则顺延心跳;空闲直接拿到;他人持有时除非 force 否则拒绝。
|
|
210
|
+
|
|
211
|
+
返回 (ok, control_view)。
|
|
212
|
+
"""
|
|
213
|
+
client_id = str(client_id or "")
|
|
214
|
+
if not client_id:
|
|
215
|
+
client_id = "anon-" + secrets.token_hex(4)
|
|
216
|
+
with _CTRL_LOCK:
|
|
217
|
+
c = _live_ctrl()
|
|
218
|
+
if c["client_id"] == client_id:
|
|
219
|
+
c["expires_at"] = time.time() + CTRL_TTL
|
|
220
|
+
return True, control_view(client_id)
|
|
221
|
+
if c["client_id"] and not force:
|
|
222
|
+
return False, control_view(client_id)
|
|
223
|
+
_CTRL.update({"client_id": client_id,
|
|
224
|
+
"name": _safe_name(name) or "其他设备",
|
|
225
|
+
"expires_at": time.time() + CTRL_TTL})
|
|
226
|
+
return True, control_view(client_id)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def release(client_id: str) -> dict:
|
|
230
|
+
with _CTRL_LOCK:
|
|
231
|
+
if client_id and _CTRL["client_id"] == client_id:
|
|
232
|
+
_CTRL.update({"client_id": "", "name": "", "expires_at": 0.0})
|
|
233
|
+
return control_view(client_id)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def heartbeat(client_id: str):
|
|
237
|
+
"""持锁续期。返回 (是否仍持有, control_view)。"""
|
|
238
|
+
ok, view = acquire(client_id, "")
|
|
239
|
+
return ok, view
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _safe_name(s) -> str:
|
|
243
|
+
return re.sub(r"\s+", " ", str(s or "")).strip()[:24]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------- 地址探测
|
|
247
|
+
_TS_CACHE = {"ip": "", "ts": 0.0} # tailscale CLI 调用有开销,30s 缓存
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def lan_ip() -> str:
|
|
251
|
+
"""本机局域网 IP(UDP connect 只选路由不发包)。"""
|
|
252
|
+
import socket
|
|
253
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
254
|
+
try:
|
|
255
|
+
s.connect(("223.5.5.5", 80))
|
|
256
|
+
return s.getsockname()[0]
|
|
257
|
+
except Exception:
|
|
258
|
+
return ""
|
|
259
|
+
finally:
|
|
260
|
+
s.close()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def tailscale_ip(max_age: float = 30.0) -> str:
|
|
264
|
+
"""Tailscale 虚拟网 IP;未安装/未启动返回空。结果缓存 30s。"""
|
|
265
|
+
if max_age > 0 and time.time() - _TS_CACHE["ts"] < max_age:
|
|
266
|
+
return _TS_CACHE["ip"]
|
|
267
|
+
ip = _tailscale_ip_once()
|
|
268
|
+
_TS_CACHE.update({"ip": ip, "ts": time.time()})
|
|
269
|
+
return ip
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _tailscale_ip_once() -> str:
|
|
273
|
+
exe = shutil.which("tailscale")
|
|
274
|
+
if not exe:
|
|
275
|
+
return ""
|
|
276
|
+
try:
|
|
277
|
+
out = subprocess.run([exe, "ip", "-4"], capture_output=True,
|
|
278
|
+
text=True, timeout=5)
|
|
279
|
+
for line in (out.stdout or "").splitlines():
|
|
280
|
+
ip = line.strip()
|
|
281
|
+
if re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
|
|
282
|
+
return ip
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
return ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def build_connect_urls(port: int) -> list:
|
|
289
|
+
"""手机扫码可用的连接地址,按优先级排序:公网域名 > Tailscale > 局域网。"""
|
|
290
|
+
urls = []
|
|
291
|
+
if PUBLIC_URL:
|
|
292
|
+
label = ("公网 · 任何网络(重启会变,连不上重新扫码)" if PUBLIC_URL_KIND == "quick"
|
|
293
|
+
else "公网 · 任何网络")
|
|
294
|
+
urls.append({"label": label, "url": "%s/?token=%s" % (PUBLIC_URL, token())})
|
|
295
|
+
ts = tailscale_ip()
|
|
296
|
+
if ts:
|
|
297
|
+
urls.append({"label": "Tailscale · 外网随时随地",
|
|
298
|
+
"url": "http://%s:%d/?token=%s" % (ts, port, token())})
|
|
299
|
+
lan = lan_ip()
|
|
300
|
+
if lan:
|
|
301
|
+
urls.append({"label": "局域网 · 同一 WiFi",
|
|
302
|
+
"url": "http://%s:%d/?token=%s" % (lan, port, token())})
|
|
303
|
+
return urls
|
|
@@ -0,0 +1,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
|
+
阈值更宽松(5/10/15),因 Tutti 步骤级别比 dsh 工具调用粗。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# 渐进阈值:第 N 次命中时给提醒;最后一次及以上时强制停止。
|
|
21
|
+
DEFAULT_THRESHOLDS = (5, 10, 15)
|
|
22
|
+
|
|
23
|
+
REMINDER_TEMPLATE = (
|
|
24
|
+
"⚠️ 提醒:上一步使用了几乎相同的输入(已连续 {count} 次调用 {role})。"
|
|
25
|
+
"请换一种方法:读不同的文件、用更具体的指令、或明确询问用户澄清。"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
STOP_TEMPLATE = (
|
|
29
|
+
"已连续 {count} 次使用相同输入调用 {role},超过 {last_thr} 次阈值,"
|
|
30
|
+
"强制停止本 step 以防止无限循环。请用户检查输入或调整 prompt 模板。"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _prompt_fingerprint(prompt: str) -> str:
|
|
35
|
+
"""prompt 指纹:用前 200 字符 + 总长度(CLI prompt 头几行通常是模板部分)。
|
|
36
|
+
|
|
37
|
+
不做严格 canonical args 比对(CLI prompt 通常很长,且 dsh 也用 JSON.stringify);
|
|
38
|
+
指纹命中说明是同一个模板路径走过多次,已经够分辨。
|
|
39
|
+
|
|
40
|
+
用 SHA-256 取前 12 字符(96 bit):48 亿分之一碰撞概率对去重足够;不追求密码学强度。
|
|
41
|
+
"""
|
|
42
|
+
head = (prompt or "")[:200]
|
|
43
|
+
return hashlib.sha256(f"{head}|{len(prompt or '')}".encode("utf-8")).hexdigest()[:12]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RepeatGuard:
|
|
47
|
+
"""每 (run_id, step_role) 独立计数;key = prompt 指纹。
|
|
48
|
+
|
|
49
|
+
计数仅统计相同 key 连续出现;新 prompt 会重置。
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, thresholds=DEFAULT_THRESHOLDS, *, max_history=50):
|
|
53
|
+
self.thresholds = tuple(sorted(set(thresholds)))
|
|
54
|
+
self.max_history = max_history
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
# chain[(run_id, role)] = [(ts, fingerprint)]
|
|
57
|
+
self._chains: dict = {}
|
|
58
|
+
|
|
59
|
+
def check(self, run_id: str, role: str, prompt: str) -> dict:
|
|
60
|
+
"""检查并更新 (run_id, role) 的连续计数。
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
{"count": int, "reminder": str | None, "should_stop": bool}
|
|
64
|
+
"""
|
|
65
|
+
fp = _prompt_fingerprint(prompt)
|
|
66
|
+
chain_key = (run_id, role)
|
|
67
|
+
with self._lock:
|
|
68
|
+
chain = self._chains.setdefault(chain_key, [])
|
|
69
|
+
chain.append((time.time(), fp))
|
|
70
|
+
# 仅统计相同 fp 连续出现
|
|
71
|
+
count = 1
|
|
72
|
+
for _, prev_fp in reversed(chain[:-1]):
|
|
73
|
+
if prev_fp == fp:
|
|
74
|
+
count += 1
|
|
75
|
+
else:
|
|
76
|
+
break
|
|
77
|
+
# 截尾避免内存膨胀
|
|
78
|
+
if len(chain) > self.max_history:
|
|
79
|
+
self._chains[chain_key] = chain[-self.max_history:]
|
|
80
|
+
|
|
81
|
+
reminder = None
|
|
82
|
+
stop = False
|
|
83
|
+
last_thr = self.thresholds[-1]
|
|
84
|
+
if count >= last_thr:
|
|
85
|
+
stop = True
|
|
86
|
+
reminder = STOP_TEMPLATE.format(count=count, role=role, last_thr=last_thr)
|
|
87
|
+
elif count in self.thresholds:
|
|
88
|
+
reminder = REMINDER_TEMPLATE.format(count=count, role=role)
|
|
89
|
+
|
|
90
|
+
if reminder:
|
|
91
|
+
log.warning("repeat-guard %s/%s count=%d stop=%s reminder=%s",
|
|
92
|
+
run_id, role, count, stop, reminder[:80])
|
|
93
|
+
return {"count": count, "reminder": reminder, "should_stop": stop}
|
|
94
|
+
|
|
95
|
+
def reset(self, run_id: str) -> int:
|
|
96
|
+
"""重置某 run_id 的所有 (run_id, *) 链。返回清除的链条数。"""
|
|
97
|
+
with self._lock:
|
|
98
|
+
cleared = 0
|
|
99
|
+
for k in list(self._chains.keys()):
|
|
100
|
+
if k[0] == run_id:
|
|
101
|
+
del self._chains[k]
|
|
102
|
+
cleared += 1
|
|
103
|
+
return cleared
|
|
104
|
+
|
|
105
|
+
def stats(self, run_id: str, role: str) -> dict:
|
|
106
|
+
"""读取 (run_id, role) 当前状态(不更新)。用于 UI 调试。"""
|
|
107
|
+
chain_key = (run_id, role)
|
|
108
|
+
with self._lock:
|
|
109
|
+
chain = self._chains.get(chain_key, [])
|
|
110
|
+
if not chain:
|
|
111
|
+
return {"chain_len": 0, "last_fp": ""}
|
|
112
|
+
# 当前连续计数
|
|
113
|
+
last_fp = chain[-1][1]
|
|
114
|
+
count = 1
|
|
115
|
+
for _, fp in reversed(chain[:-1]):
|
|
116
|
+
if fp == last_fp:
|
|
117
|
+
count += 1
|
|
118
|
+
else:
|
|
119
|
+
break
|
|
120
|
+
return {"chain_len": len(chain), "last_fp": last_fp, "count": count}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# 模块级默认实例(pipeline 接线用;run_id 入键天然隔离多任务,测试无需替换)
|
|
124
|
+
guard = RepeatGuard()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import 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
|
+
|
|
13
|
+
MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _binding_bonus(agent_id):
|
|
17
|
+
"""绑定链可用性加分/减分:链上有可用条目 +8,解析为空(回落 CLI 本机默认)
|
|
18
|
+
-25。2026-09-16 实测:静态能力基线让配额烧干的 codex 永远压过健康备用 CLI,
|
|
19
|
+
绑定空的 CLI 更是连用户配置的模型都没用上——先按「能不能按配置跑起来」校准。"""
|
|
20
|
+
try:
|
|
21
|
+
from . import modelhub
|
|
22
|
+
b = modelhub.resolve_binding(agent_id)
|
|
23
|
+
return 8.0 if (b and b.get("call_chain")) else -25.0
|
|
24
|
+
except Exception:
|
|
25
|
+
return 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _history_bonus(stats, agent_id, ttype):
|
|
29
|
+
s = (stats.get(agent_id) or {}).get(ttype) or {}
|
|
30
|
+
if not s.get("runs"):
|
|
31
|
+
return 0.0
|
|
32
|
+
win_rate = s["wins"] / s["runs"]
|
|
33
|
+
return round(18.0 * win_rate + min(6.0, s["runs"]), 1) # 胜率为主,经验为辅
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def score(agent, role, ttype, stats=None):
|
|
37
|
+
"""返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
|
|
38
|
+
quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
|
|
39
|
+
(封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
|
|
40
|
+
stats = stats or {}
|
|
41
|
+
base = CAPABILITY.get(agent.get("kind"), 60)
|
|
42
|
+
bb = _binding_bonus(agent.get("id"))
|
|
43
|
+
btxt = ""
|
|
44
|
+
if bb > 0:
|
|
45
|
+
btxt = ",绑定链可用(+%s)" % bb
|
|
46
|
+
elif bb < 0:
|
|
47
|
+
btxt = ",绑定链为空将回落 CLI 本机默认配置(%s)" % bb
|
|
48
|
+
hb = _history_bonus(stats, agent.get("id"), ttype)
|
|
49
|
+
total = base + bb + hb
|
|
50
|
+
hs = (stats.get(agent.get("id")) or {}).get(ttype)
|
|
51
|
+
htxt = (",历史 %d/%d 胜(+%s)" % (hs["wins"], hs["runs"], hb)) if hs else ",无历史记录"
|
|
52
|
+
quota_txt = ""
|
|
53
|
+
quota = int(agent.get("quota_tokens_per_hour") or 0)
|
|
54
|
+
if quota > 0:
|
|
55
|
+
try:
|
|
56
|
+
from . import usage
|
|
57
|
+
used = usage.agent_tokens_recent(agent.get("id"), hours=1)
|
|
58
|
+
except Exception:
|
|
59
|
+
used = 0
|
|
60
|
+
if used > 0:
|
|
61
|
+
ratio = min(1.0, used / float(quota))
|
|
62
|
+
penalty = round(-45.0 * ratio, 1)
|
|
63
|
+
if penalty:
|
|
64
|
+
total += penalty
|
|
65
|
+
quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
|
|
66
|
+
return total, "能力基线 %d%s%s%s,总分 %s" % (base, btxt, htxt, quota_txt, round(total, 1))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pick(agents, role, ttype, stats=None, exclude=()):
|
|
70
|
+
"""按分选出最优智能体。返回 (agent, 理由) 或 (None, "")。"""
|
|
71
|
+
stats = stats or history.agent_stats()
|
|
72
|
+
best, best_reason = None, ""
|
|
73
|
+
for a in agents:
|
|
74
|
+
if a["id"] in exclude:
|
|
75
|
+
continue
|
|
76
|
+
total, reason = score(a, role, ttype, stats)
|
|
77
|
+
if best is None or total > best[0]:
|
|
78
|
+
best, best_reason = (total, a), reason
|
|
79
|
+
if best is None:
|
|
80
|
+
return None, ""
|
|
81
|
+
return best[1], best_reason
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def pick_reviewer(agents, impl, ttype, stats=None):
|
|
85
|
+
"""评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
|
|
86
|
+
评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
|
|
87
|
+
绝不把回退伪装成跨厂商。"""
|
|
88
|
+
stats = stats or history.agent_stats()
|
|
89
|
+
impl_kind = impl.get("kind")
|
|
90
|
+
real = [a for a in agents if a.get("mode") == "real" and a["id"] != impl["id"]]
|
|
91
|
+
cross = [a for a in real if a.get("kind") != impl_kind]
|
|
92
|
+
if cross:
|
|
93
|
+
best = max(cross, key=lambda a: score(a, "review", ttype, stats)[0])
|
|
94
|
+
_, reason = score(best, "review", ttype, stats)
|
|
95
|
+
return best, "跨厂商评审(%s ≠ %s):%s" % (best.get("kind"), impl_kind, reason)
|
|
96
|
+
if real:
|
|
97
|
+
best = max(real, key=lambda a: score(a, "review", ttype, stats)[0])
|
|
98
|
+
_, reason = score(best, "review", ttype, stats)
|
|
99
|
+
return best, ("(无跨厂商智能体可用,回退同厂商 %s 评审——建议启用其他厂商的 CLI)"
|
|
100
|
+
% impl_kind) + reason
|
|
101
|
+
mb = next((a for a in agents if a["id"] == "mock-b"), None)
|
|
102
|
+
if mb and impl.get("mode") != "mock":
|
|
103
|
+
return mb, "(无第二个真实智能体,用 mock 评审)"
|
|
104
|
+
return impl, "(自评:仅有实现者一个智能体可用)"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def pick_critics(agents, ttype, stats=None, impl=None):
|
|
108
|
+
"""小说评审组:全部真实智能体(排除作者本人——自评是最典型的同族盲区);
|
|
109
|
+
面板与作者同厂商时在路由依据里明示,供用户决定是否补充其他厂商。
|
|
110
|
+
没有任何真实智能体时用内置 mock。"""
|
|
111
|
+
stats = stats or history.agent_stats()
|
|
112
|
+
real = [a for a in agents if a.get("mode") == "real" and (not impl or a["id"] != impl["id"])]
|
|
113
|
+
if real:
|
|
114
|
+
note = "全部真实智能体参与(按历史表现自动选择)"
|
|
115
|
+
if impl and impl.get("mode") == "real" \
|
|
116
|
+
and all(a.get("kind") == impl.get("kind") for a in real):
|
|
117
|
+
note += ";评审组与作者同为 %s,建议启用其他厂商 CLI 交叉评审" % impl.get("kind")
|
|
118
|
+
return real, note
|
|
119
|
+
mocks = [a for a in agents if a.get("mode") == "mock"] or agents[:2]
|
|
120
|
+
return mocks, "(无真实智能体,用内置 mock 演示)"
|