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.
@@ -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
- CREATE_NO_WINDOW = 0x08000000
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
- argv = ["cmd", "/c", shell_cmd]
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
- return argv, stdin_text, prompt
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(eff_agent, kind, sid, readonly,
880
- att["model"], prompt,
881
- images=images if kind == "codex" else None,
882
- workdir=workdir)
883
- res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
884
- timeout=timeout, cancel_event=cancel_event, log_path=log_path,
885
- stall_timeout=stall_t)
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 {}
@@ -18,18 +18,22 @@ SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进
18
18
  """
19
19
  from __future__ import annotations
20
20
 
21
- import json
22
- import os
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
- _PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
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=["cmd", "/c", "npm", "view", _PKG_NAME, "--json"], timeout=60)
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=["cmd", "/c", "npm", "view", _PKG_NAME, "readme"], timeout=60)
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
- run = store.create_run("mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
159
- entry_id="__self__", op="selfupgrade")
160
- jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
161
- return {"run_id": run["id"]}
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=["cmd", "/c", "npm", "install", "-g", _PKG_NAME + "@latest"],
168
- cwd=str(paths.ROOT), timeout=900, log_path=log_path)
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
- cwd=str(paths.APP_DIR),
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)
@@ -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
- DEFAULTS = {"max_concurrent_jobs": 3, "default_workdir": ""}
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")
@@ -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: token_meter 压力估算用模型名
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()