codebee 0.1.4 → 0.1.6
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 +44 -0
- package/README.md +8 -0
- package/app/core/attachments.py +40 -0
- package/app/core/bookmeta.py +157 -52
- package/app/core/bookmeta_catalog.py +67 -90
- package/app/core/builtin_agent.py +190 -8
- package/app/core/flows.py +328 -328
- package/app/core/gitmod.py +82 -8
- package/app/core/health.py +55 -1
- package/app/core/history.py +8 -2
- package/app/core/jobs.py +19 -4
- package/app/core/manager.py +159 -1
- package/app/core/modelhub.py +148 -15
- package/app/core/pipeline.py +2392 -2267
- package/app/core/registry.py +4 -0
- package/app/core/router.py +13 -7
- package/app/core/runner.py +194 -21
- package/app/core/selfupdate.py +60 -11
- package/app/core/store.py +4 -2
- package/app/main.py +101 -7
- package/app/ui/app.js +447 -115
- package/app/ui/i18n.js +32 -10
- package/app/ui/icons/bee.svg +79 -0
- package/app/ui/index.html +8 -3
- package/app/ui/style.css +3226 -2764
- package/package.json +2 -1
package/app/core/registry.py
CHANGED
|
@@ -66,6 +66,10 @@ def _build_agent(entry):
|
|
|
66
66
|
# 小时级 token 配额(可选,0/缺省=不限):路由时对本小时用量超标的
|
|
67
67
|
# 智能体降权(munder-difflin 式配额感知),订阅型 CLI 不至于被单任务打爆
|
|
68
68
|
"quota_tokens_per_hour": int(orch.get("quota_tokens_per_hour") or 0),
|
|
69
|
+
# 整包透传 orch:per-agent 运行时开关(timeout_ms 5E、stall_timeout_s
|
|
70
|
+
# 看门狗等)都在 runner 侧读取——此前只挑字段,catalog 上配的
|
|
71
|
+
# timeout_ms/stall 根本流不到 runner(2026-09-17 连载 c35 实测)
|
|
72
|
+
"orch": orch,
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
|
package/app/core/router.py
CHANGED
|
@@ -14,9 +14,10 @@ MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def _binding_bonus(agent_id):
|
|
17
|
-
"""绑定链可用性加分/减分:链上有可用条目 +8
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
"""绑定链可用性加分/减分:链上有可用条目 +8,解析为空 -25。2026-09-16 实测:
|
|
18
|
+
静态能力基线让配额烧干的 codex 永远压过健康备用 CLI,绑定空的 CLI 更是连
|
|
19
|
+
用户配置的模型都没用上——先按「能不能按配置跑起来」校准。2026-09-17 起
|
|
20
|
+
空链步骤在 pipeline 直接判失败(不再静默回落本机默认),此处只管排序。"""
|
|
20
21
|
try:
|
|
21
22
|
from . import modelhub
|
|
22
23
|
b = modelhub.resolve_binding(agent_id)
|
|
@@ -29,8 +30,13 @@ def _history_bonus(stats, agent_id, ttype):
|
|
|
29
30
|
s = (stats.get(agent_id) or {}).get(ttype) or {}
|
|
30
31
|
if not s.get("runs"):
|
|
31
32
|
return 0.0
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
runs = s["runs"]
|
|
34
|
+
win_rate = s["wins"] / runs
|
|
35
|
+
# 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
|
|
36
|
+
# 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
|
|
37
|
+
# 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
|
|
38
|
+
loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
|
|
39
|
+
return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
|
|
34
40
|
|
|
35
41
|
|
|
36
42
|
def score(agent, role, ttype, stats=None):
|
|
@@ -44,11 +50,11 @@ def score(agent, role, ttype, stats=None):
|
|
|
44
50
|
if bb > 0:
|
|
45
51
|
btxt = ",绑定链可用(+%s)" % bb
|
|
46
52
|
elif bb < 0:
|
|
47
|
-
btxt = "
|
|
53
|
+
btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
|
|
48
54
|
hb = _history_bonus(stats, agent.get("id"), ttype)
|
|
49
55
|
total = base + bb + hb
|
|
50
56
|
hs = (stats.get(agent.get("id")) or {}).get(ttype)
|
|
51
|
-
htxt = (",历史 %d/%d
|
|
57
|
+
htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
|
|
52
58
|
quota_txt = ""
|
|
53
59
|
quota = int(agent.get("quota_tokens_per_hour") or 0)
|
|
54
60
|
if quota > 0:
|
package/app/core/runner.py
CHANGED
|
@@ -58,6 +58,28 @@ def resolve_command(command):
|
|
|
58
58
|
return [path]
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _npm_shim_bypass(argv):
|
|
62
|
+
"""generic 类 CLI 把提示词嵌进 argv;经 cmd /c 重解析时引号/特殊字符会把
|
|
63
|
+
提示词截烂(kimi 实测:模型只看到 UTF-8 须知、任务本体整段丢失)。识别
|
|
64
|
+
标准 npm .cmd 垫片(%_prog% + %dp0% 目标脚本),改 node 直启绕开 cmd。
|
|
65
|
+
识别不出标准形态原样返回。"""
|
|
66
|
+
if len(argv) >= 3 and argv[0] == "cmd" and argv[1] == "/c" \
|
|
67
|
+
and str(argv[2]).lower().endswith((".cmd", ".bat")):
|
|
68
|
+
shim = argv[2]
|
|
69
|
+
try:
|
|
70
|
+
text = open(shim, encoding="utf-8", errors="replace").read()
|
|
71
|
+
except Exception:
|
|
72
|
+
return argv
|
|
73
|
+
m = re.search(r'%_prog%"\s+"?%dp0%(\\[^"\n]+?\.(?:mjs|js))"?', text)
|
|
74
|
+
if not m:
|
|
75
|
+
return argv
|
|
76
|
+
script = os.path.normpath(
|
|
77
|
+
os.path.join(os.path.dirname(shim), m.group(1).lstrip("\\")))
|
|
78
|
+
node = os.path.join(os.path.dirname(shim), "node.exe")
|
|
79
|
+
return [node if os.path.isfile(node) else "node", script] + list(argv[3:])
|
|
80
|
+
return argv
|
|
81
|
+
|
|
82
|
+
|
|
61
83
|
def _kill_tree(pid):
|
|
62
84
|
try:
|
|
63
85
|
subprocess.run(
|
|
@@ -81,18 +103,21 @@ def _drain_streams(proc, t_out, t_err, *, timeout=10):
|
|
|
81
103
|
t.join(timeout=remaining)
|
|
82
104
|
|
|
83
105
|
|
|
84
|
-
def _pipe_reader(stream, chunks, log_fh):
|
|
106
|
+
def _pipe_reader(stream, chunks, log_fh, stamp=None):
|
|
85
107
|
"""持续读子进程管道并实时落盘。
|
|
86
108
|
|
|
87
109
|
必须用 read1():BufferedReader.read(n) 会阻塞到凑满 n 字节或 EOF,
|
|
88
110
|
在长命令(npm 安装等)上等于"进程结束才一次性返回",日志面板全程空白。
|
|
89
111
|
read1() 只要有数据就返回,日志才能真正边跑边看。
|
|
112
|
+
stamp:共享 [最后输出时刻],停滞看门狗据此判定进程是否卡死。
|
|
90
113
|
"""
|
|
91
114
|
while True:
|
|
92
115
|
b = stream.read1(65536)
|
|
93
116
|
if not b:
|
|
94
117
|
break
|
|
95
118
|
chunks.append(b)
|
|
119
|
+
if stamp is not None:
|
|
120
|
+
stamp[0] = time.time()
|
|
96
121
|
if log_fh:
|
|
97
122
|
try:
|
|
98
123
|
log_fh.write(b)
|
|
@@ -258,16 +283,22 @@ def pretty_cli_log(text, max_event_chars=4000):
|
|
|
258
283
|
|
|
259
284
|
|
|
260
285
|
def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
261
|
-
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None
|
|
286
|
+
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None,
|
|
287
|
+
stall_timeout=0):
|
|
262
288
|
"""通用子进程执行:并发读管道防死锁;超时/取消杀整棵进程树。
|
|
263
289
|
|
|
264
|
-
|
|
290
|
+
stall_timeout:停滞看门狗(秒,0=关闭)——超过该时长 stdout/stderr 无任何
|
|
291
|
+
新输出即判卡死,提前杀树返回(timed_out=True + stalled=True)。只对输出
|
|
292
|
+
持续流动的 CLI 开(codex JSONL 事件流);claude json 到结束才一次性输出,
|
|
293
|
+
开了会把正常长任务误杀。
|
|
294
|
+
|
|
295
|
+
返回 {ok, exit_code, stdout, stderr, duration, cancelled, timed_out, stalled}。
|
|
265
296
|
"""
|
|
266
297
|
if shell_cmd:
|
|
267
298
|
argv = ["cmd", "/c", shell_cmd]
|
|
268
299
|
if argv is None:
|
|
269
300
|
return {"ok": False, "exit_code": None, "stdout": "", "stderr": "argv 为空",
|
|
270
|
-
"duration": 0.0, "cancelled": False, "timed_out": False}
|
|
301
|
+
"duration": 0.0, "cancelled": False, "timed_out": False, "stalled": False}
|
|
271
302
|
full_env = scrub_env(os.environ.copy(), mode="drop")
|
|
272
303
|
if env:
|
|
273
304
|
# 5A:env 关键字环境变量注入用户传入的 env(属于有意注入,例如模型 API key)
|
|
@@ -302,8 +333,9 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
302
333
|
"stderr": "启动失败: %r" % e, "duration": 0.0,
|
|
303
334
|
"cancelled": False, "timed_out": False}
|
|
304
335
|
out_chunks, err_chunks = [], []
|
|
305
|
-
|
|
306
|
-
|
|
336
|
+
stamp = [time.time()] # 最后输出时刻(两条管道共同刷新)
|
|
337
|
+
t_out = threading.Thread(target=_pipe_reader, args=(proc.stdout, out_chunks, log_fh, stamp), daemon=True)
|
|
338
|
+
t_err = threading.Thread(target=_pipe_reader, args=(proc.stderr, err_chunks, log_fh, stamp), daemon=True)
|
|
307
339
|
t_out.start()
|
|
308
340
|
t_err.start()
|
|
309
341
|
if stdin_text is not None:
|
|
@@ -319,7 +351,7 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
319
351
|
pass
|
|
320
352
|
threading.Thread(target=_feed, daemon=True).start()
|
|
321
353
|
start = time.time()
|
|
322
|
-
cancelled = timed_out = False
|
|
354
|
+
cancelled = timed_out = stalled = False
|
|
323
355
|
while True:
|
|
324
356
|
try:
|
|
325
357
|
proc.wait(timeout=0.4)
|
|
@@ -336,6 +368,14 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
336
368
|
_kill_tree(proc.pid)
|
|
337
369
|
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
338
370
|
break
|
|
371
|
+
if stall_timeout and time.time() - stamp[0] > stall_timeout:
|
|
372
|
+
# 停滞看门狗:长静默多为卡死(网关挂起/CLI 假死),与其耗满总
|
|
373
|
+
# 超时不如提前杀——错误按超时归类,走既有的换模型/换将链路
|
|
374
|
+
stalled = True
|
|
375
|
+
timed_out = True
|
|
376
|
+
_kill_tree(proc.pid)
|
|
377
|
+
_drain_streams(proc, t_out, t_err, timeout=5)
|
|
378
|
+
break
|
|
339
379
|
duration = round(time.time() - start, 1)
|
|
340
380
|
t_out.join(timeout=5)
|
|
341
381
|
t_err.join(timeout=5)
|
|
@@ -350,12 +390,15 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
350
390
|
pass
|
|
351
391
|
if cancelled:
|
|
352
392
|
stderr += "\n[已被用户取消]"
|
|
393
|
+
elif stalled:
|
|
394
|
+
stderr += "\n[输出停滞 %ss,已终止进程树]" % stall_timeout
|
|
353
395
|
elif timed_out:
|
|
354
396
|
stderr += "\n[超时 %ss,已终止进程树]" % timeout
|
|
355
397
|
return {
|
|
356
398
|
"ok": exit_code == 0 and not cancelled and not timed_out,
|
|
357
399
|
"exit_code": exit_code, "stdout": stdout, "stderr": stderr,
|
|
358
400
|
"duration": duration, "cancelled": cancelled, "timed_out": timed_out,
|
|
401
|
+
"stalled": stalled,
|
|
359
402
|
}
|
|
360
403
|
finally:
|
|
361
404
|
if log_fh:
|
|
@@ -405,6 +448,28 @@ def _parse_codex_jsonl(stdout):
|
|
|
405
448
|
return text, usage, sid
|
|
406
449
|
|
|
407
450
|
|
|
451
|
+
def _codex_work_events(stdout):
|
|
452
|
+
"""统计本轮事件流里真实「动了手」的事件数(命令执行/文件改动/MCP 工具)。
|
|
453
|
+
|
|
454
|
+
实现步空转闸的客观判据:2026-09-17 mo-so 实现步模型一条命令都没发,纯口头
|
|
455
|
+
谎报「进程执行被策略拦截」却 exit 0 + 一段像模像样的总结——text 看不出敷衍,
|
|
456
|
+
command_execution 计数为 0 才是硬证据。"""
|
|
457
|
+
n = 0
|
|
458
|
+
for line in stdout.splitlines():
|
|
459
|
+
line = line.strip()
|
|
460
|
+
if not line.startswith("{"):
|
|
461
|
+
continue
|
|
462
|
+
try:
|
|
463
|
+
ev = json.loads(line)
|
|
464
|
+
except Exception:
|
|
465
|
+
continue
|
|
466
|
+
if ev.get("type") == "item.completed" and (
|
|
467
|
+
ev.get("item") or {}).get("type") in (
|
|
468
|
+
"command_execution", "file_change", "mcp_tool_call"):
|
|
469
|
+
n += 1
|
|
470
|
+
return n
|
|
471
|
+
|
|
472
|
+
|
|
408
473
|
def _codex_fail_msg(stdout):
|
|
409
474
|
"""从 JSONL 事件流提取终态失败消息;无失败返回 ""。
|
|
410
475
|
|
|
@@ -435,10 +500,21 @@ def _codex_fail_msg(stdout):
|
|
|
435
500
|
|
|
436
501
|
|
|
437
502
|
def _parse_claude_json(stdout):
|
|
503
|
+
data = None
|
|
438
504
|
try:
|
|
439
505
|
data = json.loads(stdout)
|
|
440
506
|
except Exception:
|
|
441
|
-
|
|
507
|
+
# stream-json:逐行事件取最后一条 type=="result"(与旧单 JSON 同构)
|
|
508
|
+
for line in stdout.splitlines():
|
|
509
|
+
line = line.strip()
|
|
510
|
+
if not line.startswith("{"):
|
|
511
|
+
continue
|
|
512
|
+
try:
|
|
513
|
+
ev = json.loads(line)
|
|
514
|
+
except Exception:
|
|
515
|
+
continue
|
|
516
|
+
if isinstance(ev, dict) and ev.get("type") == "result":
|
|
517
|
+
data = ev
|
|
442
518
|
if not isinstance(data, dict):
|
|
443
519
|
return None
|
|
444
520
|
u = data.get("usage") or {}
|
|
@@ -478,6 +554,7 @@ def _model_flag(kind, model):
|
|
|
478
554
|
|
|
479
555
|
_TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
|
|
480
556
|
"unavailable", "overloaded", "rate limit", "timeout", "timed out",
|
|
557
|
+
"输出停滞",
|
|
481
558
|
# 2026-09-15 连载验收实测:网关故障形态远不止 HTTP 5xx——
|
|
482
559
|
# Z.ai 报 "400 [1211] Unknown Model"(模型临时下架)、qwen 连本地
|
|
483
560
|
# 端点 ECONNREFUSED、codex initialize 空响应,这些都被旧表判成
|
|
@@ -585,7 +662,21 @@ def _resolve_attempts(agent):
|
|
|
585
662
|
for m in (models_to_try or [None])[:3]]
|
|
586
663
|
|
|
587
664
|
|
|
588
|
-
def
|
|
665
|
+
def _codex_sandbox(readonly):
|
|
666
|
+
"""codex 沙箱档位。写步默认 danger-full-access(2026-09-17 用户拍板「默认给全部
|
|
667
|
+
权限」):workspace-write 禁网+禁盘外写,模型偶发还会误判沙箱受限、谎报
|
|
668
|
+
「进程执行被策略拦截」直接躺平(mo-so BUG#27596 实测,沙箱探针证明命令本可跑)。
|
|
669
|
+
只读步(评审/规划)仍 read-only——评审者可写会污染 git diff 裁决。
|
|
670
|
+
env TUTTI_CODEX_SANDBOX 可钉死某档(workspace-write / read-only / danger-full-access)。"""
|
|
671
|
+
if readonly:
|
|
672
|
+
return "read-only"
|
|
673
|
+
env = os.environ.get("TUTTI_CODEX_SANDBOX", "").strip().lower()
|
|
674
|
+
if env in ("workspace-write", "read-only", "danger-full-access"):
|
|
675
|
+
return env
|
|
676
|
+
return "danger-full-access"
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=None):
|
|
589
680
|
"""构建一次 CLI 调用的 (argv, stdin_text, prompt)。model 可为 None=CLI 默认。
|
|
590
681
|
images 为图片附件绝对路径:codex 用 -i 原生附图;其余 kind 忽略(调用方已过滤)。"""
|
|
591
682
|
env = {}
|
|
@@ -602,14 +693,13 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
|
|
|
602
693
|
"--skip-git-repo-check", "--json"]
|
|
603
694
|
if model:
|
|
604
695
|
argv += ["-m", model]
|
|
605
|
-
argv += ["-c", 'sandbox_mode="%s"' %
|
|
606
|
-
("read-only" if readonly else "workspace-write")]
|
|
696
|
+
argv += ["-c", 'sandbox_mode="%s"' % _codex_sandbox(readonly)]
|
|
607
697
|
if cp:
|
|
608
698
|
argv += _codex_provider_args(cp)
|
|
609
699
|
else:
|
|
610
700
|
argv = resolve_command(agent["command"]) + [
|
|
611
701
|
"exec", "--skip-git-repo-check", "--json",
|
|
612
|
-
"-s",
|
|
702
|
+
"-s", _codex_sandbox(readonly)]
|
|
613
703
|
if model:
|
|
614
704
|
argv += ["-m", model]
|
|
615
705
|
if cp:
|
|
@@ -620,7 +710,10 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
|
|
|
620
710
|
argv += ["-i", p]
|
|
621
711
|
stdin_text = prompt
|
|
622
712
|
elif kind == "claude":
|
|
623
|
-
|
|
713
|
+
# stream-json(2026-09-17 起):逐行事件边跑边吐——停滞看门狗有得看,
|
|
714
|
+
# 步骤日志实时可读;result 末行与旧单 JSON 同构,解析器双形态兼容
|
|
715
|
+
argv = resolve_command(agent["command"]) + [
|
|
716
|
+
"-p", "--output-format", "stream-json", "--verbose"]
|
|
624
717
|
if sid:
|
|
625
718
|
argv += ["--resume", sid]
|
|
626
719
|
if model:
|
|
@@ -629,15 +722,27 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
|
|
|
629
722
|
# 实测本机自定义网关在 -p 模式下工具续接会丢最终结果(用工具必空)。
|
|
630
723
|
# 评审/规划所需的上下文已内嵌在提示词中,显式禁用工具最稳。
|
|
631
724
|
prompt = "(请勿使用任何工具,直接依据下方内容回答。)\n\n" + prompt
|
|
632
|
-
|
|
725
|
+
elif os.environ.get("TUTTI_CLAUDE_PERMS", "").strip() == "acceptEdits":
|
|
633
726
|
argv += ["--permission-mode", "acceptEdits"]
|
|
727
|
+
else:
|
|
728
|
+
# 无人值守 -p 下 acceptEdits 只自动放行改文件,Bash 一律权限拒绝 →
|
|
729
|
+
# 模型以为环境受限谎报「被策略拦截」躺平(同 codex 沙箱误判案)。
|
|
730
|
+
# 默认跳过全部权限检查(2026-09-17 用户拍板),env 可收回旧行为。
|
|
731
|
+
argv += ["--dangerously-skip-permissions"]
|
|
634
732
|
stdin_text = prompt
|
|
635
733
|
elif kind == "opencode":
|
|
636
734
|
argv = resolve_command(agent["command"]) + ["run"]
|
|
735
|
+
if workdir:
|
|
736
|
+
# opencode 的 shell 不总跟进程 cwd 走(服务端复用时会落在旧项目根,
|
|
737
|
+
# 2026-09-17 mo-so 实测 agent 在 Temp 里全盘找代码)——显式钉死
|
|
738
|
+
argv += ["--dir", str(workdir)]
|
|
637
739
|
if sid:
|
|
638
740
|
argv += ["-s", sid] # 无头续会话:-s 指定会话 id(-c 只能接最近一次)
|
|
639
741
|
if model:
|
|
640
|
-
|
|
742
|
+
# opencode 只认 provider/model 全名(裸名直接 UnknownError,实测);
|
|
743
|
+
# CodeBee 同步进其配置的 provider id 固定为 orch,故裸名补前缀。
|
|
744
|
+
# 绑定里已写全名(含 /)的尊重原值。
|
|
745
|
+
argv += ["--model", model if "/" in model else "orch/" + model]
|
|
641
746
|
stdin_text = prompt # 无位置参数且 stdin 有内容时读 stdin
|
|
642
747
|
elif kind == "qwen": # gemini-cli 系:无参数且 stdin 有内容时读 stdin
|
|
643
748
|
argv = resolve_command(agent["command"])
|
|
@@ -657,6 +762,8 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None):
|
|
|
657
762
|
tmpl = agent["resume_argv_template"]
|
|
658
763
|
argv = resolve_command(agent["command"]) + [
|
|
659
764
|
str(a).replace("{prompt}", prompt).replace("{session}", sid) for a in tmpl]
|
|
765
|
+
# 提示词在 argv 里 → 必须绕开 cmd 重解析(见 _npm_shim_bypass)
|
|
766
|
+
argv = _npm_shim_bypass(argv)
|
|
660
767
|
if "{prompt}" not in tmpl:
|
|
661
768
|
stdin_text = prompt # 恢复模板不带 {prompt}:提示词走 stdin(mimo 实测支持)
|
|
662
769
|
return argv, stdin_text, prompt
|
|
@@ -682,9 +789,20 @@ def _check_approval(agent):
|
|
|
682
789
|
return False, "sensitive step requires policy=ask UI flow (Phase 5) — currently blocking"
|
|
683
790
|
|
|
684
791
|
|
|
792
|
+
def _stall_timeout(env_name, default):
|
|
793
|
+
"""停滞看门狗秒数:stdout/stderr 持续无新输出超过该时长 → 判卡死提前杀,
|
|
794
|
+
错误按超时归类走既有换模型/换将链路。只对事件流持续流动的 CLI 启用
|
|
795
|
+
(codex JSONL / claude stream-json);qwen/opencode/generic 结束才一次性
|
|
796
|
+
输出,开了会误杀正常长任务,仍靠总超时兜底。env 可调,0=关闭。"""
|
|
797
|
+
try:
|
|
798
|
+
return max(0, int(os.environ.get(env_name, str(default))))
|
|
799
|
+
except Exception:
|
|
800
|
+
return default
|
|
801
|
+
|
|
802
|
+
|
|
685
803
|
def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
686
804
|
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
|
|
687
|
-
images=None):
|
|
805
|
+
images=None, require_tools=False):
|
|
688
806
|
"""执行一次智能体调用,返回统一结构
|
|
689
807
|
{ok, text, json, cost_usd, tokens, error, error_code, raw}。
|
|
690
808
|
agent 来自 registry.effective_agents();resume 为已有会话 id,仅真实智能体生效
|
|
@@ -692,6 +810,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
692
810
|
generic: catalog orch.resume_argv_template)。
|
|
693
811
|
images:任务图片附件的绝对路径,仅 codex 原生支持(-i);其余智能体靠
|
|
694
812
|
提示词里的 _attachments/ 路径 + 自身读文件能力获取,无读图工具时静默忽略。
|
|
813
|
+
require_tools:实现步空转闸(仅 codex 事件流可判)——True 时本轮零
|
|
814
|
+
command_execution/file_change/mcp_tool_call 事件即判 VENDOR_REFUSAL,
|
|
815
|
+
防模型纯口头谎报「环境受限」蒙混过关(2026-09-17 mo-so 实测)。
|
|
695
816
|
|
|
696
817
|
模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
|
|
697
818
|
主模型 + 降级备选;瞬态错误才换下一条,取消/超时/解析失败不降级。
|
|
@@ -703,6 +824,18 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
703
824
|
if orch_timeout_ms:
|
|
704
825
|
timeout = float(orch_timeout_ms) / 1000.0
|
|
705
826
|
kind = agent.get("kind", "generic")
|
|
827
|
+
if kind == "codex":
|
|
828
|
+
stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
|
|
829
|
+
elif kind == "claude":
|
|
830
|
+
stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
|
|
831
|
+
else:
|
|
832
|
+
# 数据驱动:catalog orch.stall_timeout_s——给 kimi 这类边写边吐进度行的
|
|
833
|
+
# CLI 配置后,静默挂死 10 分钟即杀(不必耗满总超时);qwen/mimo 这类
|
|
834
|
+
# 结束才一次性输出的留 0(开了必误杀),靠总超时兜底
|
|
835
|
+
try:
|
|
836
|
+
stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
|
|
837
|
+
except Exception:
|
|
838
|
+
stall_t = 0
|
|
706
839
|
# 5G:approval NEVER 一线(无人值守不静默降级)
|
|
707
840
|
ok, reason = _check_approval(agent)
|
|
708
841
|
if not ok:
|
|
@@ -745,9 +878,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
745
878
|
for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
|
|
746
879
|
argv, stdin_text, prompt_eff = _build_call(eff_agent, kind, sid, readonly,
|
|
747
880
|
att["model"], prompt,
|
|
748
|
-
images=images if kind == "codex" else None
|
|
881
|
+
images=images if kind == "codex" else None,
|
|
882
|
+
workdir=workdir)
|
|
749
883
|
res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
|
|
750
|
-
timeout=timeout, cancel_event=cancel_event, log_path=log_path
|
|
884
|
+
timeout=timeout, cancel_event=cancel_event, log_path=log_path,
|
|
885
|
+
stall_timeout=stall_t)
|
|
751
886
|
out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
|
|
752
887
|
"tokens": 0, "usage": None, "error": "", "error_code": "",
|
|
753
888
|
"sid": "", "raw": res, "kind": kind, "model": att["model"]}
|
|
@@ -756,9 +891,12 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
756
891
|
# stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
|
|
757
892
|
# 里——只取其一会让 _quota_error/_transient_error 判空。
|
|
758
893
|
tail = ((res["stderr"] or "") + "\n" + (res["stdout"] or "")).strip()[-600:]
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
894
|
+
head = ("输出停滞 %ss(stall timed out,疑似卡死已提前终止)" % stall_t
|
|
895
|
+
if res.get("stalled")
|
|
896
|
+
else "超时" if res["timed_out"]
|
|
897
|
+
else "取消" if res["cancelled"]
|
|
898
|
+
else "退出码 %s" % res["exit_code"])
|
|
899
|
+
out["error"] = head + (";stderr/stdout: " + tail if tail else "")
|
|
762
900
|
if kind == "codex":
|
|
763
901
|
fm = _codex_fail_msg(res["stdout"])
|
|
764
902
|
if fm:
|
|
@@ -769,6 +907,32 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
769
907
|
parsed_err = _parse_claude_json(res["stdout"] or "")
|
|
770
908
|
if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
|
|
771
909
|
out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
|
|
910
|
+
if kind == "codex" and att.get("provider_id"):
|
|
911
|
+
el = out["error"].lower()
|
|
912
|
+
if (("wire_api" in el and "no longer supported" in el)
|
|
913
|
+
or "no route matched" in el):
|
|
914
|
+
# codex 撞 chat-only 供应商(0.154 只讲 responses):
|
|
915
|
+
# 自动冷却该供应商 30 分钟,链展开/路由绑定分随之
|
|
916
|
+
# 自动绕开——不用人工改绑定(2026-09-17 mo-so 实测)
|
|
917
|
+
try:
|
|
918
|
+
from . import modelhub as _mh
|
|
919
|
+
_mh.note_codex_wire_dead(att["provider_id"])
|
|
920
|
+
except Exception:
|
|
921
|
+
pass
|
|
922
|
+
elif "no model configured" in out["error"].lower():
|
|
923
|
+
# CLI 本体没配置(kimi 实测)→ 运行期自愈:把绑定注入其
|
|
924
|
+
# 自家配置,本次换将之后的下一轮即可用
|
|
925
|
+
try:
|
|
926
|
+
from . import manager as _mg
|
|
927
|
+
note = _mg.sync_cli_config_now(agent.get("id", ""))
|
|
928
|
+
if note and log_path:
|
|
929
|
+
try:
|
|
930
|
+
with open(log_path, "a", encoding="utf-8") as fh:
|
|
931
|
+
fh.write("\n[自愈] %s\n" % note)
|
|
932
|
+
except Exception:
|
|
933
|
+
pass
|
|
934
|
+
except Exception:
|
|
935
|
+
pass
|
|
772
936
|
out["error_code"] = _classify_failure(res, kind=kind)
|
|
773
937
|
break
|
|
774
938
|
if kind == "codex":
|
|
@@ -784,6 +948,15 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
784
948
|
out["error_code"] = ErrorCode.VENDOR_ERROR
|
|
785
949
|
elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
|
|
786
950
|
out["text"] = res["stdout"][-2000:]
|
|
951
|
+
if require_tools and out["ok"] and _codex_work_events(res["stdout"]) == 0:
|
|
952
|
+
# 实现步空转闸:exit 0 + 有话但零动手 → 判拒绝(fatal 语义正确,
|
|
953
|
+
# 不把谎报的「已改完」静默传给下游;auto 模式换将逻辑按 ok 触发,
|
|
954
|
+
# 仍可换别的 CLI 再试)。原文尾段留进错误供人核对。
|
|
955
|
+
out["ok"] = False
|
|
956
|
+
out["error"] = ("实现步零工具调用:模型未执行任何命令/文件改动,"
|
|
957
|
+
"仅口头汇报(典型如谎报环境受限)。回答尾段: %s"
|
|
958
|
+
% (out["text"] or "")[-300:])
|
|
959
|
+
out["error_code"] = ErrorCode.VENDOR_REFUSAL
|
|
787
960
|
if not out["text"] and out["ok"]:
|
|
788
961
|
out["error_code"] = _classify_failure(res, kind="codex", empty_output=True)
|
|
789
962
|
elif kind == "claude":
|
package/app/core/selfupdate.py
CHANGED
|
@@ -62,23 +62,71 @@ def _ver_tuple(s):
|
|
|
62
62
|
return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
""
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
_RELNOTES_RE = re.compile(
|
|
66
|
+
r"<!--\s*relnotes:start\s*-->(.*?)<!--\s*relnotes:end\s*-->", re.S)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _relnotes(readme):
|
|
70
|
+
"""README 里 relnotes 标记段的内容(发布前手工更新,见 CHANGELOG.md 头部说明)。"""
|
|
71
|
+
m = _RELNOTES_RE.search(str(readme or ""))
|
|
72
|
+
return m.group(1).strip() if m else ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _npm_meta():
|
|
76
|
+
"""npm view <pkg> --json;返回 (latest, relnotes, err)。
|
|
77
|
+
|
|
78
|
+
relnotes 来自 registry 元数据里的 README(与查新同一条 npm 通道,不依赖
|
|
79
|
+
GitHub 连通性),展示「新版本更新内容」用。部分 npm 版本 --json 不带
|
|
80
|
+
readme 字段,此时回退到 `npm view <pkg> readme` 纯文本再提取。"""
|
|
69
81
|
r = runner.run_process(
|
|
70
|
-
argv=["cmd", "/c", "npm", "view", _PKG_NAME, "
|
|
82
|
+
argv=["cmd", "/c", "npm", "view", _PKG_NAME, "--json"], timeout=60)
|
|
71
83
|
if not r["ok"]:
|
|
72
|
-
return "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
return "", "", (r["stderr"] or r["stdout"] or "")[-200:] or "npm 命令失败"
|
|
85
|
+
ver, notes = "", ""
|
|
86
|
+
try:
|
|
87
|
+
meta = json.loads(r["stdout"] or "{}")
|
|
88
|
+
if isinstance(meta, dict):
|
|
89
|
+
ver = str(meta.get("version") or "")
|
|
90
|
+
notes = _relnotes(meta.get("readme") or "")
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
if not ver: # 旧 npm --json 失败时退回纯文本解析
|
|
94
|
+
m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
|
|
95
|
+
ver = m.group(0) if m else ""
|
|
96
|
+
if ver and not notes:
|
|
97
|
+
r2 = runner.run_process(
|
|
98
|
+
argv=["cmd", "/c", "npm", "view", _PKG_NAME, "readme"], timeout=60)
|
|
99
|
+
if r2["ok"]:
|
|
100
|
+
notes = _relnotes(r2["stdout"] or "")
|
|
101
|
+
return ver, notes, ("" if ver else "npm 输出无法解析")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def changelog_section(version):
|
|
105
|
+
"""本地 CHANGELOG.md 中 version 对应小节(升级重启后「本次更新内容」用)。"""
|
|
106
|
+
v = re.escape(str(version or "").lstrip("v"))
|
|
107
|
+
m = re.search(r"(?ms)^## v?%s\b[^\n]*$(.*?)(?=^## |\Z)" % v,
|
|
108
|
+
_read_changelog())
|
|
109
|
+
if not m:
|
|
110
|
+
return ""
|
|
111
|
+
body = m.group(0).strip()
|
|
112
|
+
return body
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _read_changelog():
|
|
116
|
+
try:
|
|
117
|
+
return (paths.ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
118
|
+
except Exception:
|
|
119
|
+
return ""
|
|
75
120
|
|
|
76
121
|
|
|
77
122
|
def check(force=False):
|
|
78
|
-
"""GET /api/selfupdate 载荷:{mode, current, latest, has_update, note
|
|
123
|
+
"""GET /api/selfupdate 载荷:{mode, current, latest, has_update, note,
|
|
124
|
+
notes, whatsnew}。notes=远端新版本的更新内容(npm readme relnotes 段);
|
|
125
|
+
whatsnew=当前版本在本地 CHANGELOG.md 的小节(升级重启后弹「本次更新内容」)。"""
|
|
79
126
|
mode = install_mode()
|
|
80
127
|
cur = package_version()
|
|
81
|
-
out = {"mode": mode, "current": cur, "latest": "", "has_update": False,
|
|
128
|
+
out = {"mode": mode, "current": cur, "latest": "", "has_update": False,
|
|
129
|
+
"note": "", "notes": "", "whatsnew": changelog_section(cur)}
|
|
82
130
|
if mode != "npm":
|
|
83
131
|
out["note"] = ("开发仓库模式:请用 git pull 更新(自动升级会覆盖未提交的代码)"
|
|
84
132
|
if mode == "repo" else "非 npm 安装,无法自动更新")
|
|
@@ -87,8 +135,9 @@ def check(force=False):
|
|
|
87
135
|
if not force and _CHECK_CACHE["result"] and \
|
|
88
136
|
time.time() - _CHECK_CACHE["ts"] < _UPDATE_TTL:
|
|
89
137
|
return dict(_CHECK_CACHE["result"])
|
|
90
|
-
latest, err =
|
|
138
|
+
latest, notes, err = _npm_meta()
|
|
91
139
|
out["latest"] = latest
|
|
140
|
+
out["notes"] = notes
|
|
92
141
|
if err:
|
|
93
142
|
out["note"] = "查询新版本失败:" + err
|
|
94
143
|
elif latest and cur:
|
package/app/core/store.py
CHANGED
|
@@ -244,8 +244,9 @@ def _valid_id(task_id):
|
|
|
244
244
|
def set_book_meta(task_id, platform, entry):
|
|
245
245
|
"""写任务的作品信息状态(book_meta[platform] = {status, data?, error?, at})。
|
|
246
246
|
|
|
247
|
-
一键生成是后台线程跑的,前端靠任务 JSON
|
|
248
|
-
|
|
247
|
+
一键生成是后台线程跑的,前端靠任务 JSON 里的这个字段看进度。这里必须
|
|
248
|
+
bump_state:SSE 存活时前端不主动拉状态,不推送则卡片停在旧状态(点生成
|
|
249
|
+
不翻「生成中」、跑完不翻「已生成」)。任务不存在返回 False。"""
|
|
249
250
|
if not _valid_id(task_id):
|
|
250
251
|
return False
|
|
251
252
|
with LOCK:
|
|
@@ -254,6 +255,7 @@ def set_book_meta(task_id, platform, entry):
|
|
|
254
255
|
return False
|
|
255
256
|
task.setdefault("book_meta", {})[platform] = entry
|
|
256
257
|
_save_json(paths.TASKS_DIR / (task_id + ".json"), task)
|
|
258
|
+
bump_state()
|
|
257
259
|
return True
|
|
258
260
|
|
|
259
261
|
|