codebee 0.1.7 → 0.1.8
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 +10 -0
- package/README.md +236 -252
- package/app/core/catalog.py +24 -5
- package/app/core/errorlog.py +179 -0
- package/app/core/flows.py +6 -2
- package/app/core/gitmod.py +45 -1
- package/app/core/health.py +48 -11
- package/app/core/jobs.py +104 -9
- package/app/core/manager.py +21 -0
- package/app/core/modelhub.py +80 -11
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +420 -88
- package/app/core/runner.py +122 -10
- package/app/core/settings.py +9 -2
- package/app/core/step_runner.py +28 -4
- package/app/core/store.py +180 -139
- package/app/core/telemetry.py +291 -0
- package/app/core/token_meter.py +18 -0
- package/app/main.py +162 -16
- package/app/pick_dialog.py +78 -0
- package/app/ui/app.js +978 -601
- package/app/ui/i18n.js +77 -6
- package/app/ui/index.html +161 -80
- package/app/ui/style.css +827 -67
- package/package.json +1 -1
package/app/core/runner.py
CHANGED
|
@@ -18,6 +18,7 @@ import re
|
|
|
18
18
|
import shutil
|
|
19
19
|
import signal
|
|
20
20
|
import subprocess
|
|
21
|
+
import tempfile
|
|
21
22
|
import threading
|
|
22
23
|
import time
|
|
23
24
|
|
|
@@ -699,12 +700,29 @@ def _codex_sandbox(readonly):
|
|
|
699
700
|
return "danger-full-access"
|
|
700
701
|
|
|
701
702
|
|
|
703
|
+
def _prompt_to_file(prompt, workdir):
|
|
704
|
+
"""超长提示词落盘(工作目录优先,保证评审 CLI 沙箱内可读),返回绝对路径。"""
|
|
705
|
+
try:
|
|
706
|
+
d = workdir if workdir and os.path.isdir(workdir) else None
|
|
707
|
+
fd, path = tempfile.mkstemp(prefix="tutti_prompt_", suffix=".md", dir=d)
|
|
708
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
709
|
+
f.write(prompt)
|
|
710
|
+
return path
|
|
711
|
+
except Exception:
|
|
712
|
+
return None
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
_ARGV_PROMPT_SAFE = 20000 # 字符。Windows CreateProcess 命令行上限 32767,留路径/参数余量
|
|
716
|
+
|
|
717
|
+
|
|
702
718
|
def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=None):
|
|
703
|
-
"""构建一次 CLI 调用的 (argv, stdin_text, prompt)。model 可为 None=CLI 默认。
|
|
704
|
-
images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。
|
|
719
|
+
"""构建一次 CLI 调用的 (argv, stdin_text, prompt, tmp_files)。model 可为 None=CLI 默认。
|
|
720
|
+
images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。
|
|
721
|
+
tmp_files:为绕开命令行长度限制落盘的指令临时文件,调用方用完(进程结束后)删除。"""
|
|
705
722
|
env = {}
|
|
706
723
|
argv = None
|
|
707
724
|
stdin_text = None
|
|
725
|
+
tmp_files = []
|
|
708
726
|
imgs = [str(p) for p in (images or []) if p]
|
|
709
727
|
if kind == "codex":
|
|
710
728
|
cp = agent.get("codex_provider")
|
|
@@ -789,7 +807,20 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
789
807
|
argv = _npm_shim_bypass(argv)
|
|
790
808
|
if "{prompt}" not in tmpl:
|
|
791
809
|
stdin_text = prompt # 恢复模板不带 {prompt}:提示词走 stdin(mimo 实测支持)
|
|
792
|
-
|
|
810
|
+
# Windows CreateProcess 命令行上限 32767 字符:全局评审会把全书文本(约 6 万
|
|
811
|
+
# 字)嵌进 argv,直接 WinError 206 启动失败(2026-09-18 七猫甜宠案,kimi 实证)。
|
|
812
|
+
# 超长时落盘临时文件、参数位换成读文件指令——评审/作者 CLI 非交互模式均带
|
|
813
|
+
# 读文件工具(kimi 0.43 实测可读),读不到的调用输出不可解析,由评审全挂
|
|
814
|
+
# 防线兜底判失败,绝不静默降级。
|
|
815
|
+
if argv is not None and stdin_text is None and len(prompt) > _ARGV_PROMPT_SAFE \
|
|
816
|
+
and argv.count(prompt) == 1:
|
|
817
|
+
pf = _prompt_to_file(prompt, workdir)
|
|
818
|
+
if pf:
|
|
819
|
+
argv[argv.index(prompt)] = (
|
|
820
|
+
"[系统] 本次完整指令因命令行长度限制已写入文件:%s\n"
|
|
821
|
+
"请先用读文件工具完整读取该文件,然后把文件内容当作你的任务指令执行。" % pf)
|
|
822
|
+
tmp_files.append(pf)
|
|
823
|
+
return argv, stdin_text, prompt, tmp_files
|
|
793
824
|
|
|
794
825
|
|
|
795
826
|
def _check_approval(agent):
|
|
@@ -899,13 +930,22 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
899
930
|
# 链内条目未注入供应商时不能沿用上一条(可能是另一家厂商)的 -c 覆盖
|
|
900
931
|
del eff_agent["codex_provider"]
|
|
901
932
|
for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
|
|
902
|
-
argv, stdin_text, prompt_eff = _build_call(
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
933
|
+
argv, stdin_text, prompt_eff, tmp_files = _build_call(
|
|
934
|
+
eff_agent, kind, sid, readonly,
|
|
935
|
+
att["model"], prompt,
|
|
936
|
+
images=images if kind == "codex" else None,
|
|
937
|
+
workdir=workdir)
|
|
938
|
+
try:
|
|
939
|
+
res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
|
|
940
|
+
timeout=timeout, cancel_event=cancel_event, log_path=log_path,
|
|
941
|
+
stall_timeout=stall_t)
|
|
942
|
+
finally:
|
|
943
|
+
# 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
|
|
944
|
+
for tf in tmp_files:
|
|
945
|
+
try:
|
|
946
|
+
os.remove(tf)
|
|
947
|
+
except OSError:
|
|
948
|
+
pass
|
|
909
949
|
out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
|
|
910
950
|
"tokens": 0, "usage": None, "error": "", "error_code": "",
|
|
911
951
|
"sid": "", "raw": res, "kind": kind, "model": att["model"]}
|
|
@@ -1019,6 +1059,44 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1019
1059
|
return out
|
|
1020
1060
|
|
|
1021
1061
|
|
|
1062
|
+
def _loads_lenient(text):
|
|
1063
|
+
"""容错 json.loads:修复字符串内未转义引号后再解析。
|
|
1064
|
+
|
|
1065
|
+
模型高频病:评审 note 里带英文直引号("第5章"冷战三天"使用…"),
|
|
1066
|
+
严格 JSON 被打碎。判据:字符串内遇到 `"` 时向后看第一个非空白字符,
|
|
1067
|
+
是 , } ] : 视为结构性收尾引号,否则按字面引号转义。"""
|
|
1068
|
+
out = []
|
|
1069
|
+
i, n = 0, len(text)
|
|
1070
|
+
in_str = False
|
|
1071
|
+
while i < n:
|
|
1072
|
+
c = text[i]
|
|
1073
|
+
if in_str:
|
|
1074
|
+
if c == "\\" and i + 1 < n:
|
|
1075
|
+
out.append(text[i:i + 2])
|
|
1076
|
+
i += 2
|
|
1077
|
+
continue
|
|
1078
|
+
if c == '"':
|
|
1079
|
+
j = i + 1
|
|
1080
|
+
while j < n and text[j] in " \t\r\n":
|
|
1081
|
+
j += 1
|
|
1082
|
+
nxt = text[j] if j < n else ""
|
|
1083
|
+
if nxt in ",}]:":
|
|
1084
|
+
in_str = False
|
|
1085
|
+
out.append(c)
|
|
1086
|
+
else:
|
|
1087
|
+
out.append('\\"')
|
|
1088
|
+
i += 1
|
|
1089
|
+
continue
|
|
1090
|
+
out.append(c)
|
|
1091
|
+
i += 1
|
|
1092
|
+
continue
|
|
1093
|
+
if c == '"':
|
|
1094
|
+
in_str = True
|
|
1095
|
+
out.append(c)
|
|
1096
|
+
i += 1
|
|
1097
|
+
return json.loads("".join(out))
|
|
1098
|
+
|
|
1099
|
+
|
|
1022
1100
|
def extract_json(text):
|
|
1023
1101
|
"""从模型回复中提取 JSON:直接解析 → ```json 围栏 → 平衡花括号扫描。"""
|
|
1024
1102
|
if not text:
|
|
@@ -1034,6 +1112,10 @@ def extract_json(text):
|
|
|
1034
1112
|
return json.loads(m.group(1))
|
|
1035
1113
|
except Exception:
|
|
1036
1114
|
pass
|
|
1115
|
+
try:
|
|
1116
|
+
return _loads_lenient(m.group(1))
|
|
1117
|
+
except Exception:
|
|
1118
|
+
pass
|
|
1037
1119
|
start = text.find("{")
|
|
1038
1120
|
while start != -1:
|
|
1039
1121
|
depth = 0
|
|
@@ -1050,3 +1132,33 @@ def extract_json(text):
|
|
|
1050
1132
|
break
|
|
1051
1133
|
start = text.find("{", start + 1)
|
|
1052
1134
|
return None
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def as_scores(gj):
|
|
1138
|
+
"""评审解析第二道网:花括号兜底扫描会掉进第一个可平衡的子对象——
|
|
1139
|
+
外层 JSON 病得重时返回的是「维度→分数」本体(无 scores 键),这里包回
|
|
1140
|
+
评审形状,否则良评审被误判成「输出不可解析」(2026-09-18 七猫案:
|
|
1141
|
+
kimi 正常出分却因内嵌引号整轮判评审全挂,连环白烧自动续跑)。"""
|
|
1142
|
+
if isinstance(gj, dict) and not isinstance(gj.get("scores"), dict):
|
|
1143
|
+
vals = {k: v for k, v in gj.items()
|
|
1144
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool)}
|
|
1145
|
+
if vals and len(vals) == len(gj) and len(vals) >= 3:
|
|
1146
|
+
return {"scores": vals, "issues": [], "summary": ""}
|
|
1147
|
+
return gj if isinstance(gj, dict) else None
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def scores_from_prose(text, dims):
|
|
1151
|
+
"""评审解析第三道网:agentic CLI 有时把 JSON 写进文件、stdout 只留中文
|
|
1152
|
+
总结(「情节 8 / 人物 8 / …」)。JSON 全灭后按维度名从正文提分;
|
|
1153
|
+
至少命中 3 个维度才认(防普通行文里的巧合数字)。"""
|
|
1154
|
+
if not text or not dims:
|
|
1155
|
+
return {}
|
|
1156
|
+
found = {}
|
|
1157
|
+
for d in dims:
|
|
1158
|
+
m = re.search(re.escape(d) + r"\s*[::/是]?\s*([0-9]+(?:\.[0-9]+)?)", text)
|
|
1159
|
+
if m:
|
|
1160
|
+
try:
|
|
1161
|
+
found[d] = float(m.group(1))
|
|
1162
|
+
except (TypeError, ValueError):
|
|
1163
|
+
pass
|
|
1164
|
+
return found if len(found) >= 3 else {}
|
package/app/core/settings.py
CHANGED
|
@@ -11,8 +11,11 @@ from . import paths
|
|
|
11
11
|
_LOCK = threading.RLock()
|
|
12
12
|
_FILE = paths.DATA_DIR / "settings.json"
|
|
13
13
|
|
|
14
|
-
# default_workdir 为空表示未自定义,用 builtin_workdir()
|
|
15
|
-
|
|
14
|
+
# default_workdir 为空表示未自定义,用 builtin_workdir() 回落;
|
|
15
|
+
# telemetry_errors:匿名错误回传开关(默认开;关掉后版本 ping/错误上传/诊断包遥测部分全部停发,
|
|
16
|
+
# 「导出诊断包」是用户手动操作不受此开关限制)
|
|
17
|
+
DEFAULTS = {"max_concurrent_jobs": 3, "default_workdir": "", "hooks_token": "",
|
|
18
|
+
"telemetry_errors": True}
|
|
16
19
|
MIN_WORKERS, MAX_WORKERS = 1, 6
|
|
17
20
|
|
|
18
21
|
|
|
@@ -77,6 +80,10 @@ def save(patch):
|
|
|
77
80
|
if err:
|
|
78
81
|
return cur, err
|
|
79
82
|
cur["default_workdir"] = wd
|
|
83
|
+
if "hooks_token" in patch:
|
|
84
|
+
cur["hooks_token"] = str(patch.get("hooks_token") or "").strip()[:128]
|
|
85
|
+
if "telemetry_errors" in patch:
|
|
86
|
+
cur["telemetry_errors"] = bool(patch.get("telemetry_errors"))
|
|
80
87
|
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
81
88
|
tmp = _FILE.with_suffix(".tmp")
|
|
82
89
|
tmp.write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
|
package/app/core/step_runner.py
CHANGED
|
@@ -22,31 +22,55 @@ log = logging.getLogger(__name__)
|
|
|
22
22
|
# 触发「压缩后重试」的错误码
|
|
23
23
|
_OVERFLOW_CODES = {ErrorCode.CONTEXT_OVERFLOW, ErrorCode.MAX_TOKENS}
|
|
24
24
|
|
|
25
|
+
# 事前预检阈值:最近一次调用的上下文锚点已占本步模型容量九成 → 第一个请求
|
|
26
|
+
# 大概率被供应商拒。只拦「大概率必死」,不提前压缩平时的高压(used() 是累计
|
|
27
|
+
# 口径,比真实上下文偏大;0.8 的响应式阈值语义与此不同,勿混用)。
|
|
28
|
+
_PRECHECK_RATIO = 0.9
|
|
29
|
+
|
|
25
30
|
|
|
26
31
|
def execute_step(session, run_agent_fn, prompt, *, model: str = "",
|
|
27
32
|
llm_caller=None, retain_tail_tokens=None, **kwargs):
|
|
28
33
|
"""执行一次 step;撑爆时压缩并守门重试一次。
|
|
29
34
|
|
|
35
|
+
事前预检(借鉴 freebuff base-chat 每步容量重估):换将切到小窗口模型后,
|
|
36
|
+
响应式路径要等供应商报 CONTEXT_OVERFLOW 才压缩——报得不干净就直接卡死。
|
|
37
|
+
先用「最近一次调用上下文 vs 本步模型容量」判一次,超阈先压缩再发。
|
|
38
|
+
|
|
30
39
|
Args:
|
|
31
40
|
session: session_log.Session(记录 replace_generation)
|
|
32
41
|
run_agent_fn: runner.run_agent(或测试替身),签名 (prompt, **kwargs) -> dict
|
|
33
42
|
prompt: 已渲染的提示词(重试时原样复用,不重新渲染)
|
|
34
|
-
model:
|
|
43
|
+
model: 本步将要使用的模型名(容量与压力估算都按它算)
|
|
35
44
|
llm_caller: 压缩摘要 LLM(None = 不压缩,永不重试)
|
|
36
45
|
**kwargs: 透传 run_agent_fn
|
|
37
46
|
|
|
38
47
|
Returns:
|
|
39
48
|
(result, retried: bool)
|
|
40
49
|
"""
|
|
50
|
+
compact_kwargs = {}
|
|
51
|
+
if retain_tail_tokens is not None:
|
|
52
|
+
compact_kwargs["retain_tail_tokens"] = retain_tail_tokens
|
|
53
|
+
if llm_caller is not None and model:
|
|
54
|
+
try:
|
|
55
|
+
from .token_meter import token_meter
|
|
56
|
+
ctx = token_meter.last_context(session.run_id)
|
|
57
|
+
cap = token_meter.capacity(model)
|
|
58
|
+
if ctx > 0 and cap > 0 and ctx > cap * _PRECHECK_RATIO:
|
|
59
|
+
gen_p0 = session.replace_generation()
|
|
60
|
+
if maybe_compact(session, model=model, llm_caller=llm_caller,
|
|
61
|
+
run_id=session.run_id, **compact_kwargs) \
|
|
62
|
+
and session.replace_generation() > gen_p0:
|
|
63
|
+
log.info("precheck: last ctx~%d > %.0f%% of cap(%s)=%d, "
|
|
64
|
+
"compacted before step", ctx, _PRECHECK_RATIO * 100,
|
|
65
|
+
model, cap)
|
|
66
|
+
except Exception:
|
|
67
|
+
log.debug("precheck skipped", exc_info=True)
|
|
41
68
|
gen0 = session.replace_generation()
|
|
42
69
|
result = run_agent_fn(prompt, **kwargs)
|
|
43
70
|
code = result.get("error_code") or ""
|
|
44
71
|
if code not in _OVERFLOW_CODES or llm_caller is None:
|
|
45
72
|
return result, False
|
|
46
73
|
# 撑爆 → 尝试压缩
|
|
47
|
-
compact_kwargs = {}
|
|
48
|
-
if retain_tail_tokens is not None:
|
|
49
|
-
compact_kwargs["retain_tail_tokens"] = retain_tail_tokens
|
|
50
74
|
compacted = maybe_compact(session, model=model, llm_caller=llm_caller,
|
|
51
75
|
run_id=session.run_id, **compact_kwargs)
|
|
52
76
|
gen1 = session.replace_generation()
|