codebee 0.1.6 → 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 +18 -0
- package/README.md +415 -421
- package/app/core/automation.py +30 -5
- package/app/core/catalog.py +45 -3
- 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 +55 -61
- package/app/core/jobs.py +104 -9
- package/app/core/manager.py +101 -11
- package/app/core/modelhub.py +2952 -2896
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +425 -99
- package/app/core/remote.py +310 -303
- package/app/core/runner.py +148 -13
- package/app/core/selfupdate.py +47 -16
- package/app/core/settings.py +9 -2
- package/app/core/step_runner.py +28 -4
- package/app/core/store.py +160 -28
- package/app/core/telemetry.py +291 -0
- package/app/core/token_meter.py +18 -0
- package/app/core/usage.py +51 -1
- package/app/main.py +379 -37
- package/app/pick_dialog.py +78 -0
- package/app/ui/app.js +1006 -266
- package/app/ui/i18n.js +107 -7
- package/app/ui/index.html +148 -55
- package/app/ui/style.css +1816 -2
- package/package.json +1 -1
package/app/core/runner.py
CHANGED
|
@@ -16,14 +16,18 @@ import json
|
|
|
16
16
|
import os
|
|
17
17
|
import re
|
|
18
18
|
import shutil
|
|
19
|
+
import signal
|
|
19
20
|
import subprocess
|
|
21
|
+
import tempfile
|
|
20
22
|
import threading
|
|
21
23
|
import time
|
|
22
24
|
|
|
23
25
|
from .env_scrub import scrub_env
|
|
24
26
|
from .error_codes import ErrorCode
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
# 非 Windows 必须置 0:POSIX 的 Popen 对非零 creationflags 直接抛 ValueError,
|
|
29
|
+
# 置 0 则两边通用(remote.py 同款守卫)。
|
|
30
|
+
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
27
31
|
DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
|
|
28
32
|
|
|
29
33
|
_BASH_CANDIDATES = [
|
|
@@ -35,6 +39,11 @@ _bash_cache = {"path": None, "done": False}
|
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
def find_git_bash():
|
|
42
|
+
"""Windows 专供:claude 原生 exe 找不到 bash 会拒绝启动,指给 Git Bash。
|
|
43
|
+
macOS/Linux 有系统 bash,claude 自会找到;强行设 CLAUDE_CODE_GIT_BASH_PATH
|
|
44
|
+
反而可能指错(/bin/bash 与 Git Bash 行为有差异),故非 Windows 一律 None。"""
|
|
45
|
+
if os.name != "nt":
|
|
46
|
+
return None
|
|
38
47
|
if _bash_cache["done"]:
|
|
39
48
|
return _bash_cache["path"]
|
|
40
49
|
_bash_cache["done"] = True
|
|
@@ -81,6 +90,19 @@ def _npm_shim_bypass(argv):
|
|
|
81
90
|
|
|
82
91
|
|
|
83
92
|
def _kill_tree(pid):
|
|
93
|
+
"""杀整棵进程树。Windows 用 taskkill /T;POSIX 靠 spawn 时的
|
|
94
|
+
start_new_session(子进程自成一个进程组,pgid==pid)用 killpg 连孙带杀。"""
|
|
95
|
+
if os.name != "nt":
|
|
96
|
+
try:
|
|
97
|
+
os.killpg(pid, signal.SIGKILL)
|
|
98
|
+
return
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
try:
|
|
102
|
+
os.kill(pid, signal.SIGKILL)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
return
|
|
84
106
|
try:
|
|
85
107
|
subprocess.run(
|
|
86
108
|
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
@@ -295,7 +317,8 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
295
317
|
返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled}。
|
|
296
318
|
"""
|
|
297
319
|
if shell_cmd:
|
|
298
|
-
|
|
320
|
+
# shell 串的解析器随平台:重定向/引号语法两边通用,只是解释器不同
|
|
321
|
+
argv = ["cmd", "/c", shell_cmd] if os.name == "nt" else ["/bin/sh", "-c", shell_cmd]
|
|
299
322
|
if argv is None:
|
|
300
323
|
return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
|
|
301
324
|
"duration": 0.0, "cancelled": False, "timed_out": False, "stalled": False}
|
|
@@ -327,7 +350,8 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
327
350
|
[str(a) for a in argv], cwd=cwd, env=full_env,
|
|
328
351
|
stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
|
|
329
352
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
330
|
-
creationflags=CREATE_NO_WINDOW
|
|
353
|
+
creationflags=CREATE_NO_WINDOW,
|
|
354
|
+
start_new_session=(os.name != "nt")) # POSIX 需独立进程组供 killpg 杀树;Windows 忽略该参数
|
|
331
355
|
except Exception as e:
|
|
332
356
|
return {"ok": False, "exit_code": None, "stdout": "",
|
|
333
357
|
"stderr": "启动失败: %r" % e, "duration": 0.0,
|
|
@@ -676,12 +700,29 @@ def _codex_sandbox(readonly):
|
|
|
676
700
|
return "danger-full-access"
|
|
677
701
|
|
|
678
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
|
+
|
|
679
718
|
def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=None):
|
|
680
|
-
"""构建一次 CLI 调用的 (argv, stdin_text, prompt)。model 可为 None=CLI 默认。
|
|
681
|
-
images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。
|
|
719
|
+
"""构建一次 CLI 调用的 (argv, stdin_text, prompt, tmp_files)。model 可为 None=CLI 默认。
|
|
720
|
+
images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。
|
|
721
|
+
tmp_files:为绕开命令行长度限制落盘的指令临时文件,调用方用完(进程结束后)删除。"""
|
|
682
722
|
env = {}
|
|
683
723
|
argv = None
|
|
684
724
|
stdin_text = None
|
|
725
|
+
tmp_files = []
|
|
685
726
|
imgs = [str(p) for p in (images or []) if p]
|
|
686
727
|
if kind == "codex":
|
|
687
728
|
cp = agent.get("codex_provider")
|
|
@@ -766,7 +807,20 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
766
807
|
argv = _npm_shim_bypass(argv)
|
|
767
808
|
if "{prompt}" not in tmpl:
|
|
768
809
|
stdin_text = prompt # 恢复模板不带 {prompt}:提示词走 stdin(mimo 实测支持)
|
|
769
|
-
|
|
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
|
|
770
824
|
|
|
771
825
|
|
|
772
826
|
def _check_approval(agent):
|
|
@@ -876,13 +930,22 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
876
930
|
# 链内条目未注入供应商时不能沿用上一条(可能是另一家厂商)的 -c 覆盖
|
|
877
931
|
del eff_agent["codex_provider"]
|
|
878
932
|
for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
|
|
879
|
-
argv, stdin_text, prompt_eff = _build_call(
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
|
886
949
|
out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
|
|
887
950
|
"tokens": 0, "usage": None, "error": "", "error_code": "",
|
|
888
951
|
"sid": "", "raw": res, "kind": kind, "model": att["model"]}
|
|
@@ -996,6 +1059,44 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
996
1059
|
return out
|
|
997
1060
|
|
|
998
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
|
+
|
|
999
1100
|
def extract_json(text):
|
|
1000
1101
|
"""从模型回复中提取 JSON:直接解析 → ```json 围栏 → 平衡花括号扫描。"""
|
|
1001
1102
|
if not text:
|
|
@@ -1011,6 +1112,10 @@ def extract_json(text):
|
|
|
1011
1112
|
return json.loads(m.group(1))
|
|
1012
1113
|
except Exception:
|
|
1013
1114
|
pass
|
|
1115
|
+
try:
|
|
1116
|
+
return _loads_lenient(m.group(1))
|
|
1117
|
+
except Exception:
|
|
1118
|
+
pass
|
|
1014
1119
|
start = text.find("{")
|
|
1015
1120
|
while start != -1:
|
|
1016
1121
|
depth = 0
|
|
@@ -1027,3 +1132,33 @@ def extract_json(text):
|
|
|
1027
1132
|
break
|
|
1028
1133
|
start = text.find("{", start + 1)
|
|
1029
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/selfupdate.py
CHANGED
|
@@ -18,18 +18,22 @@ SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进
|
|
|
18
18
|
"""
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
-
import json
|
|
22
|
-
import
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
23
24
|
import re
|
|
24
25
|
import socket
|
|
25
26
|
import subprocess
|
|
26
27
|
import sys
|
|
27
28
|
import threading
|
|
28
29
|
import time
|
|
30
|
+
from pathlib import Path
|
|
29
31
|
|
|
30
|
-
from . import paths, runner
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
from . import paths, runner
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
|
|
33
37
|
_UPDATE_TTL = 600 # 查新结果缓存(秒)
|
|
34
38
|
_LOCK = threading.Lock()
|
|
35
39
|
_CHECK_CACHE = {"ts": 0.0, "result": None}
|
|
@@ -72,6 +76,13 @@ def _relnotes(readme):
|
|
|
72
76
|
return m.group(1).strip() if m else ""
|
|
73
77
|
|
|
74
78
|
|
|
79
|
+
def _npm_argv(*args):
|
|
80
|
+
"""npm 命令 argv:Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑。"""
|
|
81
|
+
if os.name == "nt":
|
|
82
|
+
return ["cmd", "/c", "npm"] + list(args)
|
|
83
|
+
return ["npm"] + list(args)
|
|
84
|
+
|
|
85
|
+
|
|
75
86
|
def _npm_meta():
|
|
76
87
|
"""npm view <pkg> --json;返回 (latest, relnotes, err)。
|
|
77
88
|
|
|
@@ -79,7 +90,7 @@ def _npm_meta():
|
|
|
79
90
|
GitHub 连通性),展示「新版本更新内容」用。部分 npm 版本 --json 不带
|
|
80
91
|
readme 字段,此时回退到 `npm view <pkg> readme` 纯文本再提取。"""
|
|
81
92
|
r = runner.run_process(
|
|
82
|
-
argv=
|
|
93
|
+
argv=_npm_argv("view", _PKG_NAME, "--json"), timeout=60)
|
|
83
94
|
if not r["ok"]:
|
|
84
95
|
return "", "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
|
|
85
96
|
ver, notes = "", ""
|
|
@@ -95,7 +106,7 @@ def _npm_meta():
|
|
|
95
106
|
ver = m.group(0) if m else ""
|
|
96
107
|
if ver and not notes:
|
|
97
108
|
r2 = runner.run_process(
|
|
98
|
-
argv=
|
|
109
|
+
argv=_npm_argv("view", _PKG_NAME, "readme"), timeout=60)
|
|
99
110
|
if r2["ok"]:
|
|
100
111
|
notes = _relnotes(r2["stdout"] or "")
|
|
101
112
|
return ver, notes, ("" if ver else "npm 输出无法解析")
|
|
@@ -150,22 +161,41 @@ def check(force=False):
|
|
|
150
161
|
return out
|
|
151
162
|
|
|
152
163
|
|
|
153
|
-
def apply_upgrade():
|
|
164
|
+
def apply_upgrade():
|
|
154
165
|
"""发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。"""
|
|
155
166
|
if install_mode() != "npm":
|
|
156
167
|
return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
|
|
157
168
|
from . import store, jobs
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
try:
|
|
170
|
+
run = store.create_run(
|
|
171
|
+
"mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
|
|
172
|
+
entry_id="__self__", op="selfupgrade")
|
|
173
|
+
except Exception:
|
|
174
|
+
log.exception("selfupdate: 创建升级运行记录失败")
|
|
175
|
+
return {"error": "升级任务创建失败,请稍后重试"}
|
|
176
|
+
try:
|
|
177
|
+
jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
|
|
178
|
+
except Exception:
|
|
179
|
+
# The run is already durable when enqueue fails. Close it explicitly so
|
|
180
|
+
# the upgrade panel cannot remain in a misleading queued state.
|
|
181
|
+
log.exception("selfupdate: 升级任务入队失败 run=%s", run["id"])
|
|
182
|
+
try:
|
|
183
|
+
store.update_run(run["id"], status="failed",
|
|
184
|
+
error="升级任务入队失败,请稍后重试",
|
|
185
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
186
|
+
except Exception:
|
|
187
|
+
log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
|
|
188
|
+
return {"error": "升级任务入队失败,请稍后重试", "run_id": run["id"]}
|
|
189
|
+
return {"run_id": run["id"]}
|
|
162
190
|
|
|
163
191
|
|
|
164
192
|
def run_upgrade(run_id, log_path):
|
|
165
193
|
"""worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。"""
|
|
166
194
|
res = runner.run_process(
|
|
167
|
-
argv=
|
|
168
|
-
|
|
195
|
+
argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
|
|
196
|
+
# Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
|
|
197
|
+
# cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
|
|
198
|
+
cwd=str(Path.home()), timeout=900, log_path=log_path)
|
|
169
199
|
if res["ok"]:
|
|
170
200
|
with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
|
|
171
201
|
_CHECK_CACHE["result"] = None
|
|
@@ -200,9 +230,10 @@ def relaunch(port):
|
|
|
200
230
|
if port < 1 or port > 65535:
|
|
201
231
|
return False
|
|
202
232
|
subprocess.Popen(
|
|
203
|
-
[sys.executable, "main.py", "--port", str(port),
|
|
233
|
+
[sys.executable, str(paths.APP_DIR / "main.py"), "--port", str(port),
|
|
204
234
|
"--wait-port", "--no-browser"],
|
|
205
|
-
|
|
235
|
+
# 新实例 CWD 同样不得落在包内,否则下次升级 npm 改名包目录再撞 EBUSY
|
|
236
|
+
cwd=str(Path.home()),
|
|
206
237
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
207
238
|
close_fds=True,
|
|
208
239
|
creationflags=(0x00000008 | 0x00000200) if os.name == "nt" else 0)
|
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()
|