codebee 0.1.69 → 0.1.71
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 +589 -543
- package/LICENSE +21 -21
- package/README.md +509 -508
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- 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 +210 -0
- 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/browser-final-1440.png +0 -0
- package/app/browser-final-390.png +0 -0
- package/app/browser-google-fallback.png +0 -0
- package/app/browser-google-test.png +0 -0
- package/app/browser-workbench-1440.png +0 -0
- package/app/browser-workbench-390.png +0 -0
- package/app/browser-workspace-google.png +0 -0
- package/app/browser-workspace-smoke.png +0 -0
- package/app/browser-workspace.png +0 -0
- package/app/core/acceptance.py +100 -100
- package/app/core/aiflavor.py +197 -197
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -0
- package/app/core/automation.py +107 -12
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +95 -0
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +935 -930
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1673 -1234
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +7 -1
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +190 -173
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/error_codes.py +11 -2
- package/app/core/errorlog.py +137 -137
- package/app/core/evalbench.py +720 -0
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flows.py +234 -9
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +374 -374
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +22 -1
- package/app/core/manager.py +2143 -2122
- package/app/core/market_remote.py +10 -0
- package/app/core/mcp_client.py +287 -0
- package/app/core/mocks.py +64 -64
- package/app/core/modelhub.py +185 -2
- package/app/core/notify.py +201 -106
- package/app/core/novel_quality.py +189 -0
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +138 -138
- package/app/core/paths.py +4 -0
- package/app/core/pipeline.py +426 -28
- package/app/core/planner.py +243 -22
- package/app/core/plugins.py +525 -0
- 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 +655 -539
- package/app/core/publish/fanqie.py +165 -159
- package/app/core/publish/flow.py +589 -440
- package/app/core/publish/flows-fanqie-calibrated.json +336 -332
- package/app/core/publish/ledger.py +229 -203
- package/app/core/publish/manager.py +824 -692
- package/app/core/publish/qimao.py +146 -141
- package/app/core/publish/ws.py +139 -139
- package/app/core/redact.py +38 -38
- package/app/core/remotesync.py +121 -0
- package/app/core/repeat_guard.py +129 -129
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2142 -2067
- 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 +42 -4
- package/app/core/settings_schema.py +270 -262
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +122 -122
- 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/store.py +30 -8
- 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/tracing.py +144 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1035 -862
- package/app/core/volumes.py +389 -389
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2614 -2465
- package/app/current-browser.png +0 -0
- package/app/main.py +316 -17
- package/app/mcp_server.py +175 -0
- package/app/pet.py +83 -22
- package/app/pick_dialog.py +110 -110
- package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
- package/app/plugins/code-review-helper/skills/review.md +16 -0
- package/app/ui/app.js +1391 -77
- package/app/ui/artdiff.js +74 -74
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +249 -7
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +277 -13
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +845 -12
- package/app//344/275/234/345/223/201/344/277/241/346/201/257-/344/270/203/347/214/253.md +18 -0
- package/bin/tutti.js +147 -147
- package/package.json +47 -46
- package/tools/wx_reader.py +261 -261
package/app/core/redact.py
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""自由文本脱敏基础函数;不依赖业务模块,供各类本地台账复用。"""
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import re
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
_KEY_PATTERNS = (
|
|
9
|
-
(re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), "[key]"),
|
|
10
|
-
(re.compile(r"\b(?:Bearer|bearer)\s+\S+"), "Bearer [key]"),
|
|
11
|
-
(re.compile(r"(?i)\b((?:api[_-]?|access[_-]?|secret[_-]?|auth[_-]?)(?:key|token|secret))"
|
|
12
|
-
r"""["']?\s*[:=,,]\s*["']?[A-Za-z0-9._~+/=-]{8,}"""), r"\1[key]"),
|
|
13
|
-
(re.compile(r"\b[0-9a-fA-F]{40,}\b"), "[token]"),
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
_PATH_PATTERNS = (
|
|
17
|
-
(re.compile(r"(?i)\b[A-Z]:\\(?:[^\\/:*?\"<>|\r\n]+\\)*[^\\/:*?\"<>|\r\n]*"),
|
|
18
|
-
lambda m: "[path]" + m.group(0).split("\\")[-1]),
|
|
19
|
-
(re.compile(r"(?i)\b(?:\\\\[^\\\s]+\\[^\s]+)"), "[path]"),
|
|
20
|
-
(re.compile(r"(?:/Users/|/home/|~)[^\s\"':]+"),
|
|
21
|
-
lambda m: "[path]" + m.group(0).rsplit("/", 1)[-1]),
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def scrub_text(text, limit=600):
|
|
26
|
-
"""剥离常见密钥、令牌和绝对路径,再按字符上限截断。"""
|
|
27
|
-
if not isinstance(text, str):
|
|
28
|
-
text = "" if text is None else str(text)
|
|
29
|
-
out = text
|
|
30
|
-
for pattern, replacement in _KEY_PATTERNS:
|
|
31
|
-
out = pattern.sub(replacement, out)
|
|
32
|
-
for pattern, replacement in _PATH_PATTERNS:
|
|
33
|
-
try:
|
|
34
|
-
out = pattern.sub(replacement, out)
|
|
35
|
-
except Exception:
|
|
36
|
-
pass
|
|
37
|
-
out = out.strip()
|
|
38
|
-
return out[:limit] + "…" if len(out) > limit else out
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""自由文本脱敏基础函数;不依赖业务模块,供各类本地台账复用。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_KEY_PATTERNS = (
|
|
9
|
+
(re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), "[key]"),
|
|
10
|
+
(re.compile(r"\b(?:Bearer|bearer)\s+\S+"), "Bearer [key]"),
|
|
11
|
+
(re.compile(r"(?i)\b((?:api[_-]?|access[_-]?|secret[_-]?|auth[_-]?)(?:key|token|secret))"
|
|
12
|
+
r"""["']?\s*[:=,,]\s*["']?[A-Za-z0-9._~+/=-]{8,}"""), r"\1[key]"),
|
|
13
|
+
(re.compile(r"\b[0-9a-fA-F]{40,}\b"), "[token]"),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_PATH_PATTERNS = (
|
|
17
|
+
(re.compile(r"(?i)\b[A-Z]:\\(?:[^\\/:*?\"<>|\r\n]+\\)*[^\\/:*?\"<>|\r\n]*"),
|
|
18
|
+
lambda m: "[path]" + m.group(0).split("\\")[-1]),
|
|
19
|
+
(re.compile(r"(?i)\b(?:\\\\[^\\\s]+\\[^\s]+)"), "[path]"),
|
|
20
|
+
(re.compile(r"(?:/Users/|/home/|~)[^\s\"':]+"),
|
|
21
|
+
lambda m: "[path]" + m.group(0).rsplit("/", 1)[-1]),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def scrub_text(text, limit=600):
|
|
26
|
+
"""剥离常见密钥、令牌和绝对路径,再按字符上限截断。"""
|
|
27
|
+
if not isinstance(text, str):
|
|
28
|
+
text = "" if text is None else str(text)
|
|
29
|
+
out = text
|
|
30
|
+
for pattern, replacement in _KEY_PATTERNS:
|
|
31
|
+
out = pattern.sub(replacement, out)
|
|
32
|
+
for pattern, replacement in _PATH_PATTERNS:
|
|
33
|
+
try:
|
|
34
|
+
out = pattern.sub(replacement, out)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
out = out.strip()
|
|
38
|
+
return out[:limit] + "…" if len(out) > limit else out
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""备份包远程推送(WebDAV / S3 兼容 PUT 端点):data/ 不在 git 里、磁盘一坏
|
|
3
|
+
全没——本机产品额外给备份包一条出站活路。
|
|
4
|
+
|
|
5
|
+
L0 自由层(读 settings + 依赖 runner 起进程都合法)。curl -T 流式上传:
|
|
6
|
+
备份包可能 GB 级(含工作目录),绝不能整包读进内存。密钥只存本机
|
|
7
|
+
settings.json,推送时才带 Basic auth 发给用户自己配的端点。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from . import runner # L0→L2 合法方向(小层号依赖大层号);模块级绑定便于测试打桩
|
|
14
|
+
|
|
15
|
+
_MAX_TIMEOUT_S = 3600
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cfg():
|
|
19
|
+
try:
|
|
20
|
+
from . import settings
|
|
21
|
+
s = settings.load()
|
|
22
|
+
return {"enabled": bool(s.get("backup_remote_enabled")),
|
|
23
|
+
"url": str(s.get("backup_remote_url") or "").strip().rstrip("/"),
|
|
24
|
+
"user": str(s.get("backup_remote_user") or "").strip(),
|
|
25
|
+
"pass": str(s.get("backup_remote_pass") or "")}
|
|
26
|
+
except Exception:
|
|
27
|
+
return {"enabled": False, "url": "", "user": "", "pass": ""}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def push_remote(zip_path, timeout_s=1800):
|
|
31
|
+
"""把备份包 PUT 到远程端点。返回 {"ok","skipped","status","error"}。"""
|
|
32
|
+
cfg = _cfg()
|
|
33
|
+
if not cfg["enabled"] or not cfg["url"]:
|
|
34
|
+
return {"ok": False, "skipped": True, "status": 0, "error": "未启用远程备份"}
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
p = Path(zip_path)
|
|
37
|
+
if not p.is_file():
|
|
38
|
+
return {"ok": False, "skipped": False, "status": 0, "error": "备份包不存在"}
|
|
39
|
+
argv = ["curl", "-sS", "-f", "-T", str(p), "--max-time", str(max(60, timeout_s)),
|
|
40
|
+
cfg["url"] + "/" + p.name]
|
|
41
|
+
if cfg["user"] or cfg["pass"]:
|
|
42
|
+
argv += ["--user", cfg["user"] + ":" + cfg["pass"]]
|
|
43
|
+
try:
|
|
44
|
+
r = runner.run_process(argv=argv, timeout=timeout_s + 100)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return {"ok": False, "skipped": False, "status": 0, "error": str(e)[:200]}
|
|
47
|
+
err = (r.get("stderr") or "").strip().splitlines()
|
|
48
|
+
return {"ok": bool(r.get("ok")), "skipped": False,
|
|
49
|
+
"status": 200 if r.get("ok") else 0,
|
|
50
|
+
"error": "" if r.get("ok") else (err[-1] if err else "上传失败")[:200]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_remote():
|
|
54
|
+
"""连通性测试:PUT 一个小探针文件(同端点同凭据)。"""
|
|
55
|
+
import os
|
|
56
|
+
import tempfile
|
|
57
|
+
fd, p = tempfile.mkstemp(prefix="codebee-remote-", suffix=".txt")
|
|
58
|
+
try:
|
|
59
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
60
|
+
f.write("CodeBee remote backup probe")
|
|
61
|
+
return push_remote(p, timeout_s=60)
|
|
62
|
+
finally:
|
|
63
|
+
try:
|
|
64
|
+
os.unlink(p)
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _curl_common_args():
|
|
70
|
+
cfg = _cfg()
|
|
71
|
+
argv = ["curl", "-sS", "-f", "--max-time", "120"]
|
|
72
|
+
if cfg["user"] or cfg["pass"]:
|
|
73
|
+
argv += ["--user", cfg["user"] + ":" + cfg["pass"]]
|
|
74
|
+
return cfg, argv
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_remote():
|
|
78
|
+
"""列远程端点上的备份包(WebDAV PROPFIND,其他端点回落 GET 目录页)。
|
|
79
|
+
返回 (文件名列表, 错误)。仅支持 push 时同款 URL 前缀。"""
|
|
80
|
+
cfg, argv = _curl_common_args()
|
|
81
|
+
if not cfg["enabled"] or not cfg["url"]:
|
|
82
|
+
return None, "未启用远程备份"
|
|
83
|
+
r = runner.run_process(
|
|
84
|
+
argv=argv + ["-X", "PROPFIND", "-H",
|
|
85
|
+
"Content-Type: application/xml", "-H", "Depth: 1",
|
|
86
|
+
"--data-binary", "", cfg["url"] + "/"], timeout=150)
|
|
87
|
+
if not r.get("ok"):
|
|
88
|
+
err = (r.get("stderr") or "").strip().splitlines()
|
|
89
|
+
return None, (err[-1] if err else "列举失败(端点可能不支持 PROPFIND)")[:200]
|
|
90
|
+
import re as _re
|
|
91
|
+
text = str(r.get("stdout") or "")
|
|
92
|
+
names = sorted({m for m in _re.findall(
|
|
93
|
+
r"codebee-backup-\d{8}-\d{6}\.zip", text)}, reverse=True)
|
|
94
|
+
return names, None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pull_remote(name):
|
|
98
|
+
"""从远程端点拉备份包到本机 imports 目录(走现有导入向导)。
|
|
99
|
+
name 必须精确匹配 codebee-backup-*.zip 形态(防路径注入)。
|
|
100
|
+
返回 (本地路径, 错误)。"""
|
|
101
|
+
import re as _re
|
|
102
|
+
name = str(name or "").strip()
|
|
103
|
+
if not _re.match(r"^codebee-backup-\d{8}-\d{6}\.zip$", name):
|
|
104
|
+
return None, "非法的备份包名"
|
|
105
|
+
cfg, argv = _curl_common_args()
|
|
106
|
+
if not cfg["enabled"] or not cfg["url"]:
|
|
107
|
+
return None, "未启用远程备份"
|
|
108
|
+
from . import paths
|
|
109
|
+
dest_dir = paths.DATA_DIR / "imports"
|
|
110
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
dest = dest_dir / name
|
|
112
|
+
r = runner.run_process(
|
|
113
|
+
argv=argv + ["-o", str(dest), cfg["url"] + "/" + name], timeout=1900)
|
|
114
|
+
if not r.get("ok") or not dest.is_file() or dest.stat().st_size == 0:
|
|
115
|
+
try:
|
|
116
|
+
dest.unlink()
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
err = (r.get("stderr") or "").strip().splitlines()
|
|
120
|
+
return None, (err[-1] if err else "拉取失败")[:200]
|
|
121
|
+
return str(dest), None
|
package/app/core/repeat_guard.py
CHANGED
|
@@ -1,129 +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" 对应到 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()
|
|
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()
|