codebee 0.1.51 → 0.1.53
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 +12 -0
- package/README.md +4 -4
- package/app/core/automation.py +64 -4
- package/app/core/branching.py +105 -0
- package/app/core/catalog.py +21 -2
- package/app/core/knowledge.py +4 -1
- package/app/core/pipeline.py +40 -4
- package/app/core/prefs.py +25 -20
- package/app/core/runner.py +93 -8
- package/app/core/skills.py +5 -1
- package/app/core/store.py +20 -0
- package/app/ui/app.js +76 -8
- package/app/ui/i18n.js +7 -0
- package/app/ui/index.html +1 -1
- package/app/ui/style.css +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -23,6 +23,18 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
23
23
|
|
|
24
24
|
- **首选 KEY 超限自动切备用了**:同厂商多密钥此前只认欠费类文案——首选 KEY 撞上 429 限流(智谱「并发数超过限制」「每分钟Token数已超过上限」、claude 的 rate_limit_error、codex 的 "429 Too Many Requests" 等)只记一笔错误、不进冷却,每个新步骤仍从超限的首选 KEY 重新烧起。现在限流文案与欠费同口径:进冷却 30 分钟、密钥镜像自动切下一把、链展开跳过冷却中的 KEY(到期或「恢复」按钮一键回首选)。顺带补一处漏账:无显式链时「换模型回退」的链条目不带 KEY 标记,这类重试失败此前完全不记 KEY 账。
|
|
25
25
|
|
|
26
|
+
## v0.1.53(2026-09-23)
|
|
27
|
+
|
|
28
|
+
- 换将救活两类「假终态」:上游内容拒答("can't help with this" 一类 refusal)与真超时,现在都会换绑定链上下一个候选再试——此前拒答直接判死整轮任务(链上异上游候选从未被尝试),超时能不能换全凭输出尾段碰巧带 "timeout" 字样;真实案:夜间知识库整理任务被网关 gemini 拒答后整 run 白烧
|
|
29
|
+
- 同一慢上游连烧止损:链上候选连续两次相同超时即收手(换将=换壳不换命),最坏从 N×单步超时压到 2×;取消显式列为终态绝不降级
|
|
30
|
+
- 网关模型漂移可见:请求 A 模型被网关实际映射成 B 模型时(真实案:请求 deepseek-v4.1-flash 实服 gemini-3.8-flash),失败信息与步骤日志明确标注「请求 X,上游实服 Y」,排查看对病根
|
|
31
|
+
|
|
32
|
+
## v0.1.52(2026-09-22)
|
|
33
|
+
|
|
34
|
+
- 手动指定执行者优先级全面收口:用户点名的 CLI 压过一切自动选择——路由不换将、旁观不抢占、绑定不覆盖;「手动指定执行者」作为一等偏好随任务走
|
|
35
|
+
- 管理页执行者下拉可指定为对话执行者的真实 CLI(与新建任务两级菜单复用同一段逻辑)
|
|
36
|
+
- runner 静默挂死看门狗对「等结束才一次性输出」的 CLI 保留原语义,防误杀
|
|
37
|
+
|
|
26
38
|
## v0.1.51(2026-09-22)
|
|
27
39
|
|
|
28
40
|
- 智能路由:绑定链可用性细化为「有备胎」维度(解析出的调用链有第二条及以上的任务加分),静态能力基线不再把配额烧干的供应商顶在前面
|
package/README.md
CHANGED
|
@@ -21,11 +21,11 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
21
21
|
不会把你的任务内容交给任何第三方。
|
|
22
22
|
|
|
23
23
|
<!-- relnotes:start -->
|
|
24
|
-
### 最新版更新内容(v0.1.
|
|
24
|
+
### 最新版更新内容(v0.1.53)
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
26
|
+
- 换将救活两类「假终态」:上游内容拒答(refusal)与真超时都会换链上下一个候选再试,不再整轮白烧
|
|
27
|
+
- 同一慢上游连烧止损:连续两次相同超时即收手;取消绝不降级
|
|
28
|
+
- 网关模型漂移可见:请求与实际服务的模型不一致时,失败信息明确标注
|
|
29
29
|
<!-- relnotes:end -->
|
|
30
30
|
|
|
31
31
|
---
|
package/app/core/automation.py
CHANGED
|
@@ -65,7 +65,7 @@ _DEFAULTS = {"id": "", "name": "", "prompt": "", "workdir": "", "kind": "", "tim
|
|
|
65
65
|
"mode": "auto", "thinking": "standard",
|
|
66
66
|
"direct_provider_id": "", "direct_model": "",
|
|
67
67
|
"enabled": False, "created_at": "", "last_run": "", "next_run": "",
|
|
68
|
-
"run_count": 0, "last_status": ""}
|
|
68
|
+
"run_count": 0, "last_status": "", "last_run_id": ""}
|
|
69
69
|
|
|
70
70
|
# 允许通过 update() 修改的字段(id/created_at/run_count 等运行痕迹不可改)
|
|
71
71
|
_UPDATABLE = ("name", "prompt", "workdir", "kind", "time", "interval_hours",
|
|
@@ -386,8 +386,9 @@ def _fire(snapshot, now):
|
|
|
386
386
|
"""触发一个到期任务:拉起运行并推进 last_run/next_run/run_count/last_status。
|
|
387
387
|
once 触发完自动停用。拉起失败只记 error,next_run 照常推进(下个周期重试)。"""
|
|
388
388
|
status = "started"
|
|
389
|
+
run_id = ""
|
|
389
390
|
try:
|
|
390
|
-
_launch_run(snapshot)
|
|
391
|
+
run_id = _launch_run(snapshot) or ""
|
|
391
392
|
except Exception as e:
|
|
392
393
|
log.warning("automation: 定时触发 %s 失败: %r", snapshot.get("id"), e)
|
|
393
394
|
status = "error"
|
|
@@ -398,6 +399,8 @@ def _fire(snapshot, now):
|
|
|
398
399
|
cur["last_run"] = _fmt_dt(now)
|
|
399
400
|
cur["run_count"] = int(cur.get("run_count") or 0) + 1
|
|
400
401
|
cur["last_status"] = status
|
|
402
|
+
if run_id:
|
|
403
|
+
cur["last_run_id"] = run_id
|
|
401
404
|
if cur.get("kind") == "once":
|
|
402
405
|
cur["enabled"] = False
|
|
403
406
|
cur["next_run"] = ""
|
|
@@ -408,6 +411,49 @@ def _fire(snapshot, now):
|
|
|
408
411
|
|
|
409
412
|
# ---------------------------------------------------------------- 调度线程
|
|
410
413
|
|
|
414
|
+
def _default_run_status(rid):
|
|
415
|
+
"""按 run_id 查运行状态(终态回写用);查不到返回 None。"""
|
|
416
|
+
from . import store
|
|
417
|
+
try:
|
|
418
|
+
r = store.get_run(rid)
|
|
419
|
+
except Exception:
|
|
420
|
+
return None
|
|
421
|
+
return (r or {}).get("status") or None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# 测试可替换的查态钩子
|
|
425
|
+
_RUN_STATUS = _default_run_status
|
|
426
|
+
|
|
427
|
+
_RUN_TERMINAL = ("done", "failed", "cancelled")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _reconcile_statuses():
|
|
431
|
+
"""把 last_status 停在 started 的任务对账到真实运行终态。此前触发后永不
|
|
432
|
+
回写,任务卡片永远显示「运行中」(2026-09-23 实案:三个任务全部如此)。
|
|
433
|
+
store 查不到或还没到终态就保持原样,下一轮 tick 再看。"""
|
|
434
|
+
with _LOCK:
|
|
435
|
+
pending = {tid: t.get("last_run_id") for tid, t in _TASKS.items()
|
|
436
|
+
if t.get("last_run_id") and t.get("last_status") == "started"}
|
|
437
|
+
if not pending:
|
|
438
|
+
return
|
|
439
|
+
settled = {}
|
|
440
|
+
for tid, rid in pending.items():
|
|
441
|
+
st = _RUN_STATUS(rid)
|
|
442
|
+
if st in _RUN_TERMINAL:
|
|
443
|
+
settled[tid] = st
|
|
444
|
+
if not settled:
|
|
445
|
+
return
|
|
446
|
+
with _LOCK:
|
|
447
|
+
dirty = False
|
|
448
|
+
for tid, st in settled.items():
|
|
449
|
+
t = _TASKS.get(tid)
|
|
450
|
+
if (t and t.get("last_run_id") == pending[tid]
|
|
451
|
+
and t.get("last_status") == "started"):
|
|
452
|
+
t["last_status"] = st
|
|
453
|
+
dirty = True
|
|
454
|
+
if dirty:
|
|
455
|
+
_save_locked()
|
|
456
|
+
|
|
411
457
|
def _tick():
|
|
412
458
|
"""扫描一遍到期任务并逐个触发。单任务异常只跳过该条,绝不上抛。"""
|
|
413
459
|
now = datetime.now()
|
|
@@ -429,6 +475,11 @@ def _tick():
|
|
|
429
475
|
_fire(dict(t), now)
|
|
430
476
|
except Exception:
|
|
431
477
|
log.exception("automation: tick 处理任务 %s 异常,已跳过", tid)
|
|
478
|
+
# 运行终态对账:last_status 停在 started 的任务查一次真实终态(见函数注)。
|
|
479
|
+
try:
|
|
480
|
+
_reconcile_statuses()
|
|
481
|
+
except Exception:
|
|
482
|
+
log.debug("automation: 运行终态对账跳过", exc_info=True)
|
|
432
483
|
# 定时发布联动(P2.5):任务级 auto_publish 标记到点触发批量发布。
|
|
433
484
|
# 发布逻辑全在 publish/auto(护栏/幂等/单飞),这里只当调度宿主。
|
|
434
485
|
try:
|
|
@@ -581,10 +632,11 @@ def run_now(tid):
|
|
|
581
632
|
now = datetime.now()
|
|
582
633
|
run_id = ""
|
|
583
634
|
status = "started"
|
|
635
|
+
run_id = ""
|
|
584
636
|
try:
|
|
585
637
|
with _LOCK:
|
|
586
638
|
snapshot = dict(_TASKS[tid])
|
|
587
|
-
run_id = _launch_run(snapshot)
|
|
639
|
+
run_id = _launch_run(snapshot) or ""
|
|
588
640
|
except Exception as e:
|
|
589
641
|
log.warning("automation: 手动触发 %s 失败: %r", tid, e)
|
|
590
642
|
status = "error"
|
|
@@ -592,9 +644,17 @@ def run_now(tid):
|
|
|
592
644
|
t = _TASKS.get(tid)
|
|
593
645
|
if t is None:
|
|
594
646
|
return None, None
|
|
595
|
-
|
|
647
|
+
# interval 任务的 last_run 是 next_run 的计算锚点(compute_next_run 按
|
|
648
|
+
# last_run + N 小时向前滚),手动触发写进来会让重启重算把档期整体后移
|
|
649
|
+
# ——2026-09-23 实案:22:28 手动跑一轮 + 23:43 服务重启,本该 23:53
|
|
650
|
+
# 触发的档期被抹成 00:28。interval 只让调度触发推进 last_run;其余
|
|
651
|
+
# kind 的 next_run 由 time/weekday 决定,last_run 仅作展示,照旧写。
|
|
652
|
+
if t.get("kind") != "interval":
|
|
653
|
+
t["last_run"] = _fmt_dt(now)
|
|
596
654
|
t["run_count"] = int(t.get("run_count") or 0) + 1
|
|
597
655
|
t["last_status"] = status
|
|
656
|
+
if run_id:
|
|
657
|
+
t["last_run_id"] = run_id
|
|
598
658
|
_save_locked()
|
|
599
659
|
return dict(t), run_id
|
|
600
660
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""多线剧情推演(inkos 借鉴 2026-09-23):写章前一次调用生成 2-3 条分支
|
|
3
|
+
节拍计划,自带推荐择优,选中分支注入本章 BEATS;全部分支追加审计文件
|
|
4
|
+
.codebee/branch-plans.md 供回看。计划级赛马——比 prose 级 Best-of-N 省
|
|
5
|
+
一个数量级的 token。"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
BRANCH_PLAN_PROMPT = """你是网文剧情策划。基于全书设定与上一章结尾,为下一章生成 __N__ 条**彼此方向不同**的剧情分支计划。
|
|
14
|
+
|
|
15
|
+
要求:
|
|
16
|
+
- 每条分支给出:本章节拍(50-120 字:事件/冲突/推进)+ 章末钩子(一句话)+ 一句取舍理由(为什么选/不选它)
|
|
17
|
+
- 分支之间要有真实的方向差异(不同冲突来源/不同人物决定/不同信息揭示),不是同一件事换措辞
|
|
18
|
+
- 你是策划也是评委:综合大纲意图、前情、人物动机,推荐其中最有力的一条
|
|
19
|
+
|
|
20
|
+
只输出一个 ```json 代码块:
|
|
21
|
+
{"branches": [{"beats": "…", "hook": "…", "why": "…"}, …], "pick": 0}
|
|
22
|
+
pick = 推荐分支的下标(0 起)。
|
|
23
|
+
|
|
24
|
+
## 全书目标
|
|
25
|
+
__GOAL__
|
|
26
|
+
|
|
27
|
+
## 大纲上下文(本章前后)
|
|
28
|
+
__OUTLINE__
|
|
29
|
+
|
|
30
|
+
## 上一章结尾
|
|
31
|
+
__PREV__
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plan_branches(run_id, task, i, goal, outline_txt, prev, n, *, log_path=None):
|
|
36
|
+
"""生成并择优一条分支计划。返回 (beats, hook) 或 None(失败/未启用走原大纲)。
|
|
37
|
+
|
|
38
|
+
编排者一次调用产 N 条+自荐 pick;失败静默回落原大纲节拍(推演是增强
|
|
39
|
+
不是闸门)。全部分支连同 pick 追加 .codebee/branch-plans.md 审计。"""
|
|
40
|
+
if n < 2:
|
|
41
|
+
return None
|
|
42
|
+
from . import modelhub, runner
|
|
43
|
+
if n < 2:
|
|
44
|
+
return None
|
|
45
|
+
orch = modelhub.resolve_orchestrator()
|
|
46
|
+
if not orch:
|
|
47
|
+
return None
|
|
48
|
+
prov, model = orch
|
|
49
|
+
prompt = (BRANCH_PLAN_PROMPT
|
|
50
|
+
.replace("__N__", str(n))
|
|
51
|
+
.replace("__GOAL__", goal[:600])
|
|
52
|
+
.replace("__OUTLINE__", outline_txt[:3000])
|
|
53
|
+
.replace("__PREV__", (prev or "(开篇)")[-1200:]))
|
|
54
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
|
|
55
|
+
if not res.get("ok"):
|
|
56
|
+
return None
|
|
57
|
+
data = runner.extract_json(res.get("text") or "")
|
|
58
|
+
branches = (data or {}).get("branches") if isinstance(data, dict) else None
|
|
59
|
+
if not isinstance(branches, list):
|
|
60
|
+
return None
|
|
61
|
+
usable = [b for b in branches
|
|
62
|
+
if isinstance(b, dict) and str(b.get("beats") or "").strip()]
|
|
63
|
+
if len(usable) < 2:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
pick = int(data.get("pick") or 0)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
pick = 0
|
|
69
|
+
pick = max(0, min(pick, len(usable) - 1))
|
|
70
|
+
chosen = usable[pick]
|
|
71
|
+
try:
|
|
72
|
+
from . import store as _store
|
|
73
|
+
wd = _store.run_workdir(run_id) or ""
|
|
74
|
+
except Exception:
|
|
75
|
+
wd = ""
|
|
76
|
+
_append_audit(wd, i, usable, pick)
|
|
77
|
+
return (str(chosen["beats"]).strip()[:300],
|
|
78
|
+
str(chosen.get("hook") or "").strip()[:80] or None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _append_audit(workdir, chapter, branches, pick):
|
|
82
|
+
"""全部分支追加 .codebee/branch-plans.md(失败静默——审计是增强)。"""
|
|
83
|
+
if not workdir:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
p = os.path.abspath(os.path.join(str(workdir), ".codebee",
|
|
87
|
+
"branch-plans.md"))
|
|
88
|
+
root = os.path.abspath(str(workdir))
|
|
89
|
+
if root not in p.split(os.sep)[:-len([".codebee", "branch-plans.md"])]:
|
|
90
|
+
if not p.startswith(root + os.sep):
|
|
91
|
+
return
|
|
92
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
93
|
+
with open(p, "a", encoding="utf-8") as f:
|
|
94
|
+
if f.tell() == 0:
|
|
95
|
+
f.write("# 多线剧情推演(每章分支计划与择优,供回看)\n\n")
|
|
96
|
+
f.write("## 第 %d 章\n" % chapter)
|
|
97
|
+
for k, b in enumerate(branches):
|
|
98
|
+
mark = "✅ " if k == pick else ""
|
|
99
|
+
f.write("- %s%s(钩子:%s)——%s\n" % (
|
|
100
|
+
mark, str(b.get("beats") or "")[:160],
|
|
101
|
+
str(b.get("hook") or "")[:60],
|
|
102
|
+
str(b.get("why") or "")[:120]))
|
|
103
|
+
f.write("\n")
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
package/app/core/catalog.py
CHANGED
|
@@ -64,7 +64,10 @@ DEFAULT_CATALOG = [
|
|
|
64
64
|
"note": "通义千问编码 CLI(gemini-cli 系);无头走 stdin;绑定凭据经打开注入"
|
|
65
65
|
"写 settings.json 的 env 段(OPENAI_*,qwen 存在即优先 openai 兼容通道)",
|
|
66
66
|
"detect": {"cli": "qwen"},
|
|
67
|
-
|
|
67
|
+
# stall_timeout_s:静默 15 分钟即杀(2026-09-22 全书总评实案:qwen 吐完
|
|
68
|
+
# 评审内容后卡在 MCP 收尾死锁,输出归零但进程活着,stall=0 时只能干等
|
|
69
|
+
# 2400s 总超时;900s 取「评审模型调用常见上限 5 分钟」的 3 倍,防误杀)
|
|
70
|
+
"orch": {"kind": "qwen", "command": "qwen", "stall_timeout_s": 900},
|
|
68
71
|
# settings.json 顶层 "model" 已是 legacy(qwen 忽略并告警,新格式是
|
|
69
72
|
# model.name)——模型落盘由打开注入器写 OPENAI_MODEL env 负责,
|
|
70
73
|
# 这里不再走 write_model
|
|
@@ -154,7 +157,7 @@ DEFAULT_CATALOG = [
|
|
|
154
157
|
"note": "DeepSeek 官方 agent harness(dsh,profile 插件架构);无头是「一次性任务」——"
|
|
155
158
|
"答完即退、无交互后续、不支持会话恢复;任务只走位置参数(超长提示词受 Windows "
|
|
156
159
|
"命令行上限约 32k 约束);模型写进 ~/.dsh/settings.yaml 的 agent-default-model.model;"
|
|
157
|
-
"密钥由运行时自动调度或「模型调度(可选)」注入 DEEPSEEK_API_KEY(优先级最高);端点注入 DEEPSEEK_BASE_URL,"
|
|
160
|
+
"密钥由运行时自动调度或「模型调度(可选)」注入 DEEPSEEK_API_KEY(优先级最高);端点注入 DEEPSEEK_BASE_URL,"
|
|
158
161
|
"但若 settings.yaml 已固定 llm-deepseek.baseURL,则以它为准(settings 高于 env)",
|
|
159
162
|
"detect": {"cli": "dsh"},
|
|
160
163
|
"orch": {"kind": "generic", "command": "dsh",
|
|
@@ -316,6 +319,21 @@ def _apply_config_patch(entries):
|
|
|
316
319
|
e["config"] = dict(patch)
|
|
317
320
|
|
|
318
321
|
|
|
322
|
+
def _apply_stall_patch(entries):
|
|
323
|
+
"""存量数据幂等补齐:qwencode 补 stall_timeout_s=900(2026-09-22 实案)。
|
|
324
|
+
|
|
325
|
+
data/catalog.json 生成后不会再随版本重写,老清单里的 qwencode 没有
|
|
326
|
+
停滞看门狗配置(当时按「结束才一次性输出」留 0);实际验证 qwen 评审
|
|
327
|
+
会先流式吐内容再卡死在 MCP 收尾,静默看门狗能兜住。用户手写过该字段的
|
|
328
|
+
不覆盖(包括显式写 0 关闭的)。"""
|
|
329
|
+
for e in entries:
|
|
330
|
+
if e.get("id") != "qwencode":
|
|
331
|
+
continue
|
|
332
|
+
orch = e.get("orch")
|
|
333
|
+
if isinstance(orch, dict) and "stall_timeout_s" not in orch:
|
|
334
|
+
orch["stall_timeout_s"] = 900
|
|
335
|
+
|
|
336
|
+
|
|
319
337
|
def _apply_install_patch(entries):
|
|
320
338
|
# Aider 渠道整体迁移到 uv tool(跨平台、含存量数据幂等修正):anaconda 的
|
|
321
339
|
# py -3.13 装 aider 必挂在 numpy 源码构建(老 setuptools 引用 py3.12 已删除
|
|
@@ -371,6 +389,7 @@ def load(force=False):
|
|
|
371
389
|
_apply_launch_patch(entries)
|
|
372
390
|
_apply_config_patch(entries)
|
|
373
391
|
_apply_install_patch(entries)
|
|
392
|
+
_apply_stall_patch(entries)
|
|
374
393
|
_CACHE["entries"] = entries
|
|
375
394
|
return entries
|
|
376
395
|
|
package/app/core/knowledge.py
CHANGED
|
@@ -234,7 +234,10 @@ def block_for(task):
|
|
|
234
234
|
grams = (skills._text_bigrams(x.get("title"))
|
|
235
235
|
| skills._text_bigrams(x.get("body"))
|
|
236
236
|
| set(x.get("tags") or []))
|
|
237
|
-
|
|
237
|
+
# 过期降权(inkos 检索保留来源/位置的启发):同等相关性下,
|
|
238
|
+
# 可能过期的事实排在新鲜事实之后——旧知识不该压过新知识
|
|
239
|
+
return (-len(probe & grams), 1 if _is_stale(x.get("as_of")) else 0,
|
|
240
|
+
x.get("id") or "")
|
|
238
241
|
entries = sorted(entries, key=rank)
|
|
239
242
|
else:
|
|
240
243
|
entries.sort(key=lambda x: x.get("id") or "")
|
package/app/core/pipeline.py
CHANGED
|
@@ -19,7 +19,7 @@ import re
|
|
|
19
19
|
import threading
|
|
20
20
|
import time
|
|
21
21
|
|
|
22
|
-
from . import aiflavor, attachments, catalog, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
|
|
22
|
+
from . import aiflavor, attachments, branching, catalog, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
|
|
23
23
|
from . import builtin_agent
|
|
24
24
|
from . import diagnostics
|
|
25
25
|
from . import paths as paths_mod
|
|
@@ -1481,11 +1481,31 @@ def _run_direct(run, task, agents, ev, stats, mode):
|
|
|
1481
1481
|
route = {}
|
|
1482
1482
|
resume_ctx = _valid_resume(task, agents)
|
|
1483
1483
|
bi = None
|
|
1484
|
+
impl = None
|
|
1484
1485
|
direct_provider = (task.get("direct_provider_id") or "").strip()
|
|
1485
1486
|
direct_model = (task.get("direct_model") or "").strip()
|
|
1487
|
+
direct_agent = (task.get("direct_agent") or "").strip()
|
|
1486
1488
|
direct_difficulty = ("hard" if task.get("thinking") == "high" else
|
|
1487
1489
|
"easy" if task.get("thinking") == "low" else "default")
|
|
1488
|
-
if resume_ctx is None and
|
|
1490
|
+
if resume_ctx is None and direct_agent:
|
|
1491
|
+
# 手动指定执行 CLI(2026-09-23):用户点名压过一切自动(模型绑定/
|
|
1492
|
+
# 自动推荐/路由)。目标未启用编排时按续会话语义注入(installed_agent
|
|
1493
|
+
# 无视启停开关)——显式指定不该被「参与编排」开关否决。
|
|
1494
|
+
impl = _pick(agents, direct_agent)
|
|
1495
|
+
if impl is None:
|
|
1496
|
+
extra = registry.installed_agent(direct_agent, catalog.load(),
|
|
1497
|
+
manager.detect_all())
|
|
1498
|
+
if extra:
|
|
1499
|
+
agents.append(extra)
|
|
1500
|
+
impl = extra
|
|
1501
|
+
if impl is None:
|
|
1502
|
+
store.update_run(run_id, expected_status="running", status="failed",
|
|
1503
|
+
error="指定的执行 CLI「%s」不可用(未安装或未检测到),"
|
|
1504
|
+
"请在对话条改回自动推荐,或到目录页先安装" % direct_agent,
|
|
1505
|
+
ended_at=_now())
|
|
1506
|
+
return
|
|
1507
|
+
route["implementer"] = "%s(手动指定 CLI)" % (impl.get("label") or impl.get("id"))
|
|
1508
|
+
elif resume_ctx is None and direct_provider:
|
|
1489
1509
|
bi = builtin_agent.resolve(direct_provider, direct_model, direct_difficulty)
|
|
1490
1510
|
if bi is None:
|
|
1491
1511
|
store.update_run(run_id, expected_status="running", status="failed",
|
|
@@ -1507,6 +1527,8 @@ def _run_direct(run, task, agents, ev, stats, mode):
|
|
|
1507
1527
|
elif resume_ctx is not None:
|
|
1508
1528
|
impl = resume_ctx["agent"]
|
|
1509
1529
|
route["implementer"] = resume_ctx["note"]
|
|
1530
|
+
elif impl is not None:
|
|
1531
|
+
pass # 手动指定 CLI 已就位(route 亦已写)
|
|
1510
1532
|
elif mode == "manual":
|
|
1511
1533
|
impl, _ = _pick_implementer(agents, task.get("implementer"))
|
|
1512
1534
|
else:
|
|
@@ -2534,6 +2556,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2534
2556
|
scope = ("本章 = 大纲第 %d 章" % i) if start == 1 else (
|
|
2535
2557
|
"本批为第 %d–%d 章,下列按全书章号列出各章要点" % (start, end))
|
|
2536
2558
|
|
|
2559
|
+
# 多线剧情推演(inkos 借鉴 2026-09-23):serial.branches>=2 时
|
|
2560
|
+
# 写章前一次编排者调用生成 N 条分支节拍、自荐择优,选中分支替换
|
|
2561
|
+
# 本章 BEATS/HOOK(计划级赛马,省 prose 级开销);失败静默走原大纲。
|
|
2562
|
+
branch_beats = branch_hook = None
|
|
2563
|
+
try:
|
|
2564
|
+
n_branch = max(1, min(3, int(serial.get("branches") or 1)))
|
|
2565
|
+
if n_branch >= 2:
|
|
2566
|
+
got = branching.plan_branches(
|
|
2567
|
+
run_id, task, i, task["goal"], outline_txt, prev, n_branch)
|
|
2568
|
+
if got:
|
|
2569
|
+
branch_beats, branch_hook = got
|
|
2570
|
+
except Exception:
|
|
2571
|
+
branch_beats = None
|
|
2572
|
+
|
|
2537
2573
|
def _draft_prompt(vfile):
|
|
2538
2574
|
return (SERIAL_CHAPTER_PROMPT
|
|
2539
2575
|
.replace("__SKILLS__", sk_block)
|
|
@@ -2545,8 +2581,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2545
2581
|
.replace("__PREV__", prev)
|
|
2546
2582
|
.replace("__LEDGER__", ledger_txt)
|
|
2547
2583
|
.replace("__TITLE__", ch["title"])
|
|
2548
|
-
.replace("__BEATS__", ch["beats"] or "按大纲推进")
|
|
2549
|
-
.replace("__HOOK__", ch.get("hook") or "留下悬念")
|
|
2584
|
+
.replace("__BEATS__", branch_beats or ch["beats"] or "按大纲推进")
|
|
2585
|
+
.replace("__HOOK__", branch_hook or ch.get("hook") or "留下悬念")
|
|
2550
2586
|
.replace("__WORDS__", str(wpc)))
|
|
2551
2587
|
|
|
2552
2588
|
n_variants = max(1, min(3, int(serial.get("variants") or 1)))
|
package/app/core/prefs.py
CHANGED
|
@@ -14,15 +14,20 @@ from . import paths
|
|
|
14
14
|
|
|
15
15
|
_LOCK = threading.Lock()
|
|
16
16
|
|
|
17
|
-
SAVE_KEYS = ("type", "rounds", "threshold", "best_of", "chapters",
|
|
18
|
-
"words_per_chapter", "variants", "mode", "thinking"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
SAVE_KEYS = ("type", "rounds", "threshold", "best_of", "chapters",
|
|
18
|
+
"words_per_chapter", "variants", "mode", "thinking",
|
|
19
|
+
# direct 类型的对话模型偏好(2026-09-23 补全偏好记忆覆盖面):
|
|
20
|
+
# 厂商 id/模型名/推理档——下次新建 direct 任务沿用上次手选
|
|
21
|
+
"direct_provider", "direct_model", "direct_thinking")
|
|
22
|
+
_LIMITS = {"rounds": (1, 5), "threshold": (1.0, 10.0), "best_of": (1, 3),
|
|
23
|
+
"chapters": (1, 2000), "words_per_chapter": (200, 20000),
|
|
24
|
+
"variants": (1, 3)}
|
|
25
|
+
_CHOICES = {
|
|
26
|
+
"mode": ("auto", "fast", "expert", "manual"),
|
|
27
|
+
"thinking": ("auto", "low", "standard", "high"),
|
|
28
|
+
"direct_thinking": ("auto", "low", "standard", "high"),
|
|
29
|
+
}
|
|
30
|
+
_STR_KEYS = ("type", "direct_provider", "direct_model") # 自由字符串截断白名单
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
def _file():
|
|
@@ -51,17 +56,17 @@ def record(payload):
|
|
|
51
56
|
v = payload.get(key)
|
|
52
57
|
if v is None:
|
|
53
58
|
continue
|
|
54
|
-
if key
|
|
55
|
-
v = str(v)[:
|
|
56
|
-
if v:
|
|
57
|
-
cur[
|
|
58
|
-
continue
|
|
59
|
-
if key in _CHOICES:
|
|
60
|
-
v = str(v).strip().lower()
|
|
61
|
-
if v in _CHOICES[key]:
|
|
62
|
-
cur[key] = v
|
|
63
|
-
continue
|
|
64
|
-
c = _clamp(key, v)
|
|
59
|
+
if key in _STR_KEYS:
|
|
60
|
+
v = str(v).strip()[:64]
|
|
61
|
+
if v:
|
|
62
|
+
cur[key] = v
|
|
63
|
+
continue
|
|
64
|
+
if key in _CHOICES:
|
|
65
|
+
v = str(v).strip().lower()
|
|
66
|
+
if v in _CHOICES[key]:
|
|
67
|
+
cur[key] = v
|
|
68
|
+
continue
|
|
69
|
+
c = _clamp(key, v)
|
|
65
70
|
if c is not None:
|
|
66
71
|
cur[key] = c
|
|
67
72
|
tmp = _file().with_suffix(".tmp")
|
package/app/core/runner.py
CHANGED
|
@@ -823,6 +823,10 @@ def _parse_claude_json(stdout):
|
|
|
823
823
|
int(u.get("cache_creation_input_tokens") or 0)
|
|
824
824
|
usage = {"input": inp, "output": out, "cached": cached, "reasoning": 0,
|
|
825
825
|
"total": inp + out + cached}
|
|
826
|
+
# 网关实服模型:请求 deepseek-v4.1-flash 却被网关映射成 gemini-3.8-flash
|
|
827
|
+
# 之类(2026-09-23 夜班实案)——用量统计记错名、失败排查看错病根,都
|
|
828
|
+
# 靠 result 行的 modelUsage 对出来
|
|
829
|
+
served = sorted({str(k) for k in ((data.get("modelUsage") or {}) or {})})
|
|
826
830
|
return {
|
|
827
831
|
"text": data.get("result") or "",
|
|
828
832
|
"cost_usd": data.get("total_cost_usd") or 0.0,
|
|
@@ -831,6 +835,7 @@ def _parse_claude_json(stdout):
|
|
|
831
835
|
"is_error": bool(data.get("is_error")),
|
|
832
836
|
# §07 T1.1:claude -p 返回本次会话 id,供 --resume 复用
|
|
833
837
|
"sid": str(data.get("session_id") or ""),
|
|
838
|
+
"served_models": served,
|
|
834
839
|
}
|
|
835
840
|
|
|
836
841
|
|
|
@@ -898,6 +903,55 @@ RATE_LIMIT_GRACE_S = 45 # 宽限等待时长:限流窗口通常按分钟
|
|
|
898
903
|
RATE_LIMIT_GRACE_N = 1 # 每步宽限次数:只兜一次,防限流长拖整轮时间
|
|
899
904
|
|
|
900
905
|
|
|
906
|
+
# 上游内容拒答(refusal):claude CLI 把 refusal stop_reason 渲染成
|
|
907
|
+
# "API Error: <model> can't help with this. Start a new session to continue."
|
|
908
|
+
# 这是网关后面那个模型对本次会话内容的过滤决定,不是任务终态——换一家
|
|
909
|
+
# 异上游候选常常就能跑通。2026-09-23 夜班实案:doc 起草被 gemini 拒答后
|
|
910
|
+
# 判成非瞬态整链早死,链上云知声候选根本没被尝试。
|
|
911
|
+
_REFUSAL = ("can't help with this", "couldn't help with this",
|
|
912
|
+
'stop_reason":"refusal"', "content policy", "content filtering")
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _refusal_error(err):
|
|
916
|
+
err = (err or "").lower()
|
|
917
|
+
return any(k in err for k in _REFUSAL)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _fail_sig(err):
|
|
921
|
+
"""错误串的病根特征(剥掉输出尾段/数字/会话 id 等易变片段),供换将
|
|
922
|
+
循环做同因连撞止损。与 jobs._err_signature 同思路的轻量版,避免 core
|
|
923
|
+
模块环。超时错误的尾段是每次被杀时刻的输出快照,必然不同,签名只取
|
|
924
|
+
头部(;分隔符之前)。"""
|
|
925
|
+
s = str(err or "")
|
|
926
|
+
for sep in (";stderr/stdout", ";日志错误行"):
|
|
927
|
+
i = s.find(sep)
|
|
928
|
+
if i > 0:
|
|
929
|
+
s = s[:i]
|
|
930
|
+
break
|
|
931
|
+
s = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
|
|
932
|
+
"<sid>", s)
|
|
933
|
+
s = re.sub(r"\d+", "N", s)
|
|
934
|
+
return re.sub(r"\s+", " ", s).strip()[:200]
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _claude_drift_note(att_model, parsed):
|
|
938
|
+
"""请求模型 vs 网关实服模型不一致时的注记;一致或无从判断返回空。"""
|
|
939
|
+
served = (parsed or {}).get("served_models") or []
|
|
940
|
+
if not served or not att_model or served == [att_model]:
|
|
941
|
+
return ""
|
|
942
|
+
return "[模型漂移] 请求 %s,上游实服 %s" % (att_model, ",".join(served))
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _log_note(log_path, text):
|
|
946
|
+
if not log_path or not text:
|
|
947
|
+
return
|
|
948
|
+
try:
|
|
949
|
+
with open(log_path, "a", encoding="utf-8") as fh:
|
|
950
|
+
fh.write("\n[%s]\n" % text)
|
|
951
|
+
except OSError:
|
|
952
|
+
pass
|
|
953
|
+
|
|
954
|
+
|
|
901
955
|
def _rate_limited(err):
|
|
902
956
|
err = (err or "").lower()
|
|
903
957
|
return any(k in err for k in _RATE_LIMIT)
|
|
@@ -1255,7 +1309,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1255
1309
|
防模型纯口头谎报「环境受限」蒙混过关(2026-09-17 mo-so 实测)。
|
|
1256
1310
|
|
|
1257
1311
|
模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
|
|
1258
|
-
主模型 +
|
|
1312
|
+
主模型 + 降级备选;瞬态/配额/超时/上游拒答换下一条,取消与解析
|
|
1313
|
+
失败不降级;超时连续两次同签名即止损(同一慢上游换壳无用)。
|
|
1259
1314
|
|
|
1260
1315
|
5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout。
|
|
1261
1316
|
"""
|
|
@@ -1276,9 +1331,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1276
1331
|
elif kind == "claude":
|
|
1277
1332
|
stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
|
|
1278
1333
|
else:
|
|
1279
|
-
# 数据驱动:catalog orch.stall_timeout_s——给 kimi
|
|
1280
|
-
# CLI
|
|
1281
|
-
#
|
|
1334
|
+
# 数据驱动:catalog orch.stall_timeout_s——给 kimi/qwen 这类评审用
|
|
1335
|
+
# CLI 配置后,静默挂死即杀(qwen 900s:2026-09-22 MCP 收尾死锁案);
|
|
1336
|
+
# mimo 等结束才一次性输出的仍留 0(开了必误杀),靠总超时兜底
|
|
1282
1337
|
try:
|
|
1283
1338
|
stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
|
|
1284
1339
|
except Exception:
|
|
@@ -1308,6 +1363,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1308
1363
|
attempts = _resolve_attempts(agent)
|
|
1309
1364
|
out = None
|
|
1310
1365
|
grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
|
|
1366
|
+
prev_sig = "" # 上一次尝试的失败签名:同因连撞止损用
|
|
1311
1367
|
for ai, att in enumerate(attempts):
|
|
1312
1368
|
env = dict(base_env)
|
|
1313
1369
|
env.update(att["env"])
|
|
@@ -1370,6 +1426,10 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1370
1426
|
parsed_err = _parse_claude_json(res["stdout"] or "")
|
|
1371
1427
|
if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
|
|
1372
1428
|
out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
|
|
1429
|
+
drift = _claude_drift_note(att.get("model"), parsed_err)
|
|
1430
|
+
if drift:
|
|
1431
|
+
out["error"] += ";" + drift
|
|
1432
|
+
_log_note(log_path, drift)
|
|
1373
1433
|
if kind == "codex" and att.get("provider_id"):
|
|
1374
1434
|
el = out["error"].lower()
|
|
1375
1435
|
if (("wire_api" in el and "no longer supported" in el)
|
|
@@ -1442,9 +1502,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1442
1502
|
out["tokens"] = parsed["tokens"]
|
|
1443
1503
|
out["usage"] = parsed["usage"]
|
|
1444
1504
|
out["sid"] = parsed.get("sid") or "" # §07 T1.1
|
|
1505
|
+
drift = _claude_drift_note(att.get("model"), parsed)
|
|
1506
|
+
if drift:
|
|
1507
|
+
_log_note(log_path, drift)
|
|
1445
1508
|
if parsed["is_error"]:
|
|
1446
1509
|
out["ok"] = False
|
|
1447
1510
|
out["error"] = "claude 返回 is_error: " + parsed["text"][:500]
|
|
1511
|
+
if drift:
|
|
1512
|
+
out["error"] += ";" + drift
|
|
1448
1513
|
out["error_code"] = _classify_failure(res, kind="claude", parsed=parsed)
|
|
1449
1514
|
break
|
|
1450
1515
|
if not out["text"] and attempt == 0:
|
|
@@ -1471,10 +1536,30 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1471
1536
|
attempts.append(att)
|
|
1472
1537
|
continue
|
|
1473
1538
|
return out
|
|
1474
|
-
#
|
|
1475
|
-
#
|
|
1476
|
-
|
|
1477
|
-
|
|
1539
|
+
# 换将闸门:什么失败值得烧链上下一个候选。
|
|
1540
|
+
# · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
|
|
1541
|
+
# · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
|
|
1542
|
+
# 2026-09-23 夜班实案:旧逻辑里超时能否换将全赌输出尾部碰巧含有
|
|
1543
|
+
# "timeout" 字样(当时是步骤里读到的代码 timeout=900 撞的表)。
|
|
1544
|
+
# · 上游内容拒答(refusal):是那家模型的过滤决定,不是任务终态,
|
|
1545
|
+
# 换异上游候选常常就活;判死则整 run 白烧(同案:gemini 拒答后
|
|
1546
|
+
# 链上云知声候选从未被尝试)。
|
|
1547
|
+
raw = out.get("raw") or {}
|
|
1548
|
+
if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
|
|
1549
|
+
or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
|
|
1550
|
+
or _refusal_error(out.get("error"))
|
|
1551
|
+
or raw.get("timed_out"))):
|
|
1552
|
+
return out # 取消/解析失败等真终态不降级
|
|
1553
|
+
# 超时同因连撞止损:头部签名(超时/输出停滞)连续相同就收手——
|
|
1554
|
+
# 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
|
|
1555
|
+
# 2026-09-23 夜班实案同一慢上游连烧 3×20 分钟全超时零产出),最坏
|
|
1556
|
+
# 情况从 N×步超时压到 2×。其余瞬态/拒答仍按链长全部尝试:各家病根
|
|
1557
|
+
# 不同,每个候选都值得一次机会。
|
|
1558
|
+
if raw.get("timed_out"):
|
|
1559
|
+
sig = _fail_sig(out.get("error"))
|
|
1560
|
+
if sig and sig == prev_sig:
|
|
1561
|
+
return out
|
|
1562
|
+
prev_sig = sig
|
|
1478
1563
|
return out
|
|
1479
1564
|
|
|
1480
1565
|
|
package/app/core/skills.py
CHANGED
|
@@ -295,7 +295,11 @@ def list_lessons(scope=None, only_enabled=False, category=None):
|
|
|
295
295
|
if (x.get("category") or LESSON_UNCATEGORIZED) == category]
|
|
296
296
|
if only_enabled:
|
|
297
297
|
items = [x for x in items if x.get("enabled", True)]
|
|
298
|
-
|
|
298
|
+
# karma 感知排序(UI 列表用;注入排序在 relevance_top 不走这里):
|
|
299
|
+
# 有效教训(won-lost 高)排最前——经验库页首屏即「真实帮上忙的」,
|
|
300
|
+
# 其次按注入热度;失守多的自然沉底(不隐藏——降权不删除)。
|
|
301
|
+
items.sort(key=lambda x: (-(int(x.get("won") or 0) - int(x.get("lost") or 0)),
|
|
302
|
+
-int(x.get("hits") or 0), -int(x.get("seen") or 1),
|
|
299
303
|
x.get("created_at") or ""))
|
|
300
304
|
return items
|
|
301
305
|
|
package/app/core/store.py
CHANGED
|
@@ -135,6 +135,11 @@ def create_task(payload):
|
|
|
135
135
|
task["direct_model"] = _text(payload.get("direct_model"), "direct_model")[:160]
|
|
136
136
|
if task["direct_model"] and not task["direct_provider_id"]:
|
|
137
137
|
raise ValueError("指定对话模型时必须同时指定厂商")
|
|
138
|
+
# 手动指定执行 CLI(2026-09-23):优先级高于模型绑定——运行时走该 CLI
|
|
139
|
+
# 的会话链而非内置智能体。存 id(如 codex-cli),可用性在运行时校验
|
|
140
|
+
da = _text(payload.get("direct_agent"), "direct_agent")[:80]
|
|
141
|
+
if da:
|
|
142
|
+
task["direct_agent"] = da
|
|
138
143
|
# 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
|
|
139
144
|
from . import gitmod
|
|
140
145
|
git_rev = _text(payload.get("git_rev"), "git_rev")
|
|
@@ -218,6 +223,15 @@ def create_task(payload):
|
|
|
218
223
|
v = 1
|
|
219
224
|
if v > 1:
|
|
220
225
|
s["variants"] = v
|
|
226
|
+
# 多线剧情推演(inkos 借鉴 2026-09-23):2-3 = 写章前生成
|
|
227
|
+
# N 条分支节拍计划择优注入(计划级赛马,省 prose 级开销);
|
|
228
|
+
# 越界钳到 3(与 variants 同口径:上限仍可用,1=不启用不落键)
|
|
229
|
+
try:
|
|
230
|
+
br = max(1, min(3, int(serial.get("branches") or 1)))
|
|
231
|
+
except Exception:
|
|
232
|
+
br = 1
|
|
233
|
+
if br > 1:
|
|
234
|
+
s["branches"] = br
|
|
221
235
|
# 分卷:显式卷表优先(新建任务时用户明确给出的分卷信息),
|
|
222
236
|
# 其次每卷章数等长切卷。卷边界一律由 volumes 模块从「章号」
|
|
223
237
|
# 确定性推导——续写批次沿用同一份卷表/每卷章数即自动接上同一卷,
|
|
@@ -1511,6 +1525,12 @@ def update_task_params(task_id, patch):
|
|
|
1511
1525
|
task["direct_provider_id"] = pv
|
|
1512
1526
|
task["direct_model"] = mv
|
|
1513
1527
|
changed = True
|
|
1528
|
+
# 执行 CLI 可改/可清(清空 = 回内置智能体或模型绑定);与模型绑定
|
|
1529
|
+
# 允许共存,运行时 CLI 优先
|
|
1530
|
+
av = str((patch or {}).get("direct_agent") or "").strip()[:80]
|
|
1531
|
+
if task.get("direct_agent") != av and (av or task.get("direct_agent")):
|
|
1532
|
+
task["direct_agent"] = av
|
|
1533
|
+
changed = True
|
|
1514
1534
|
if changed:
|
|
1515
1535
|
_save_json(paths.TASKS_DIR / (task_id + ".json"), task)
|
|
1516
1536
|
bump_state()
|
package/app/ui/app.js
CHANGED
|
@@ -105,28 +105,59 @@ window.syncCmpSelFace = syncCmpSelFace; // applyI18n 重译 option 后同步
|
|
|
105
105
|
* 菜单只改它们的值——既有提交流 / onTypeChange 显隐 / 测试全兼容。 */
|
|
106
106
|
let cmpModelProv = ""; // ""=厂商级;否则=已钻入的厂商 id(模型级)
|
|
107
107
|
|
|
108
|
+
/* 可指定为对话执行者的真实 CLI(手动指定 CLI,2026-09-23):
|
|
109
|
+
* state.agents 即 effective_agents(已装+启用编排),mode=real 排掉 mock */
|
|
110
|
+
function realCliAgents() {
|
|
111
|
+
return ((S.state && S.state.agents) || []).filter((a) => a && a.mode === "real");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cmpDirectAgentVal() {
|
|
115
|
+
const el = $("f-direct-agent");
|
|
116
|
+
return el ? String(el.value || "").trim() : "";
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
function cmpDirectBtnSync() {
|
|
109
120
|
const btn = $("f-direct-btn");
|
|
110
121
|
if (!btn) return;
|
|
122
|
+
const av = cmpDirectAgentVal();
|
|
123
|
+
if (av) {
|
|
124
|
+
const a = realCliAgents().find((x) => x.id === av);
|
|
125
|
+
btn.textContent = (a ? (a.label || a.id) : av) + " · CLI";
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
111
128
|
const pv = $("f-direct-provider").value, mv = $("f-direct-model-name").value;
|
|
112
129
|
if (!pv) { btn.textContent = t("自动推荐"); return; }
|
|
113
130
|
const p = directProviders().find((x) => x.id === pv);
|
|
114
131
|
btn.textContent = (p ? (p.name || p.id) : pv) + (mv ? "/" + mv : "/" + t("推荐"));
|
|
115
132
|
}
|
|
116
133
|
|
|
134
|
+
/* 菜单里「指定 CLI」分组(两级菜单复用同一段,差别只在真源变量):
|
|
135
|
+
* entriesHtml(选中id, 空文案) 返回分组 HTML;空列表返回 "" */
|
|
136
|
+
function cliMenuSectionHtml(selectedId) {
|
|
137
|
+
const clis = realCliAgents();
|
|
138
|
+
if (!clis.length) return "";
|
|
139
|
+
const chk = '<svg class="ico ti-check" aria-hidden="true"><use href="#i-check"></use></svg>';
|
|
140
|
+
return '<div class="type-menu-cap">' + t("指定 CLI") + "</div>" +
|
|
141
|
+
clis.map((a) =>
|
|
142
|
+
'<button type="button" class="type-item" data-cli="' + esc(a.id) + '"><span class="ti-body"><span class="ti-name">' +
|
|
143
|
+
esc(a.label || a.id) + "</span></span>" + (selectedId === a.id ? chk : "") + "</button>").join("");
|
|
144
|
+
}
|
|
145
|
+
|
|
117
146
|
function cmpModelMenuRender() {
|
|
118
147
|
const menu = $("cmp-model-menu");
|
|
119
148
|
if (!menu) return;
|
|
120
149
|
const pv = $("f-direct-provider").value, mv = $("f-direct-model-name").value;
|
|
150
|
+
const av = cmpDirectAgentVal();
|
|
121
151
|
const chk = '<svg class="ico ti-check" aria-hidden="true"><use href="#i-check"></use></svg>';
|
|
122
152
|
const arr = '<svg class="ico ti-check" aria-hidden="true"><use href="#i-chevron-r"></use></svg>';
|
|
123
153
|
if (!cmpModelProv) {
|
|
124
154
|
menu.innerHTML =
|
|
125
155
|
'<button type="button" class="type-item" data-p="" data-m=""><span class="ti-body"><span class="ti-name">' +
|
|
126
|
-
t("自动推荐") + "</span></span>" + (!pv ? chk : "") + "</button>" +
|
|
156
|
+
t("自动推荐") + "</span></span>" + (!pv && !av ? chk : "") + "</button>" +
|
|
127
157
|
directProviders().map((p) =>
|
|
128
158
|
'<button type="button" class="type-item" data-p="' + esc(p.id) + '"><span class="ti-body"><span class="ti-name">' +
|
|
129
|
-
esc(p.name || p.id) + "</span></span>" + (pv === p.id ? chk : arr) + "</button>").join("") +
|
|
159
|
+
esc(p.name || p.id) + "</span></span>" + (pv === p.id && !av ? chk : arr) + "</button>").join("") +
|
|
160
|
+
cliMenuSectionHtml(av) +
|
|
130
161
|
'<div class="type-menu-foot"><button type="button" class="type-item" data-manage="1"><span class="ti-body"><span class="ti-name">' +
|
|
131
162
|
t("管理模型…") + "</span></span></button></div>";
|
|
132
163
|
} else {
|
|
@@ -162,10 +193,17 @@ window.toggleModelMenu = toggleModelMenu;
|
|
|
162
193
|
// innerHTML 重写后 e.target 是已脱离 DOM 的旧节点,wrap.contains 会误判
|
|
163
194
|
// false 而把刚钻取的菜单收掉(真机「点厂商没反应」根因)
|
|
164
195
|
e.stopPropagation();
|
|
165
|
-
const b = e.target.closest("[data-p],[data-back],[data-manage]");
|
|
196
|
+
const b = e.target.closest("[data-p],[data-back],[data-manage],[data-cli]");
|
|
166
197
|
if (!b) return;
|
|
167
198
|
if (b.dataset.manage) { toggleModelMenu(false); switchTab("models"); return; }
|
|
168
199
|
if (b.dataset.back) { cmpModelProv = ""; cmpModelMenuRender(); return; }
|
|
200
|
+
if (b.dataset.cli !== undefined) {
|
|
201
|
+
// 手动指定执行 CLI:写真源即可,模型绑定保留在任务上(运行时 CLI 优先)
|
|
202
|
+
$("f-direct-agent").value = b.dataset.cli;
|
|
203
|
+
cmpDirectBtnSync();
|
|
204
|
+
toggleModelMenu(false);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
169
207
|
if (b.dataset.m === undefined) {
|
|
170
208
|
// 点厂商 = 只钻取到模型级(用户拍板 2026-09-21:模型必须亲手点,
|
|
171
209
|
// 不要自动带推荐)。真源不动——用户点了具体模型才写 provider/model。
|
|
@@ -175,7 +213,9 @@ window.toggleModelMenu = toggleModelMenu;
|
|
|
175
213
|
}
|
|
176
214
|
// 选定:写回隐藏 select 真源(提交流/回归测试全走原路径)。顺序关键:
|
|
177
215
|
// 先让 provider 的 option 就位再赋值,否则 select.value 静默失效(真机
|
|
178
|
-
// 「没法选模型」根因:options 由 poll
|
|
216
|
+
// 「没法选模型」根因:options 由 poll 重建,可能仍是残缺态)。
|
|
217
|
+
// 选定模型/自动推荐同时清除 CLI 指定(三者互斥,模型生效=走内置智能体)
|
|
218
|
+
$("f-direct-agent").value = "";
|
|
179
219
|
renderDirectModelPicker();
|
|
180
220
|
$("f-direct-provider").value = b.dataset.p;
|
|
181
221
|
renderDirectModelPicker();
|
|
@@ -2466,9 +2506,11 @@ async function createTask() {
|
|
|
2466
2506
|
if (_eng === "code") {
|
|
2467
2507
|
payload.verify_command = $("f-verify").value.trim();
|
|
2468
2508
|
} else if (_eng === "direct") {
|
|
2469
|
-
//
|
|
2509
|
+
// 直连:目标+附件即全部输入,不带验证/评审参数;direct_agent 为空时走
|
|
2510
|
+
// 内置智能体/模型绑定(后端按空串处理)
|
|
2470
2511
|
payload.direct_provider_id = $("f-direct-provider").value;
|
|
2471
2512
|
payload.direct_model = $("f-direct-model-name").value;
|
|
2513
|
+
payload.direct_agent = cmpDirectAgentVal();
|
|
2472
2514
|
} else {
|
|
2473
2515
|
payload.manuscript = $("f-manuscript").value.trim() || "manuscript.md";
|
|
2474
2516
|
payload.rounds = parseInt($("f-rounds").value, 10) || 2;
|
|
@@ -3153,6 +3195,8 @@ function newFromTask(id) {
|
|
|
3153
3195
|
$("f-direct-provider").value = tk.direct_provider_id || "";
|
|
3154
3196
|
renderDirectModelPicker();
|
|
3155
3197
|
$("f-direct-model-name").value = tk.direct_model || "";
|
|
3198
|
+
$("f-direct-agent").value = tk.direct_agent || "";
|
|
3199
|
+
cmpDirectBtnSync();
|
|
3156
3200
|
} else {
|
|
3157
3201
|
$("f-manuscript").value = tk.manuscript || "manuscript.md";
|
|
3158
3202
|
$("f-rounds").value = tk.rounds || 2;
|
|
@@ -7318,6 +7362,7 @@ function renderDetailOverview(run, runs, task) {
|
|
|
7318
7362
|
* 回填自当前任务;改动即写任务(空闲态),发送 /chat 前再兜底同步一次。 */
|
|
7319
7363
|
let rdPrefsTaskId = "";
|
|
7320
7364
|
let rdModelPv = "", rdModelMv = ""; // 对话条当前模型选择(厂商 id + 模型名)
|
|
7365
|
+
let rdModelAgent = ""; // 手动指定的执行 CLI(空 = 不指定)
|
|
7321
7366
|
|
|
7322
7367
|
function rdPrefsFill(task) {
|
|
7323
7368
|
if (!task) return;
|
|
@@ -7328,6 +7373,7 @@ function rdPrefsFill(task) {
|
|
|
7328
7373
|
rdPrefsTaskId = task.id;
|
|
7329
7374
|
rdModelPv = task.direct_provider_id || "";
|
|
7330
7375
|
rdModelMv = task.direct_model || "";
|
|
7376
|
+
rdModelAgent = task.direct_agent || "";
|
|
7331
7377
|
}
|
|
7332
7378
|
["rd-mode", "rd-thinking"].forEach((id) => syncCmpSelFace(id));
|
|
7333
7379
|
rdModelFaceSync();
|
|
@@ -7336,6 +7382,11 @@ function rdPrefsFill(task) {
|
|
|
7336
7382
|
function rdModelFaceSync() {
|
|
7337
7383
|
const face = $("rd-model-btn");
|
|
7338
7384
|
if (!face) return;
|
|
7385
|
+
if (rdModelAgent) {
|
|
7386
|
+
const a = realCliAgents().find((x) => x.id === rdModelAgent);
|
|
7387
|
+
face.textContent = (a ? (a.label || a.id) : rdModelAgent) + " · CLI";
|
|
7388
|
+
return;
|
|
7389
|
+
}
|
|
7339
7390
|
if (!rdModelPv) { face.textContent = t("自动推荐"); return; }
|
|
7340
7391
|
const p = directProviders().find((x) => x.id === rdModelPv);
|
|
7341
7392
|
face.textContent = (p ? (p.name || p.id) : rdModelPv) +
|
|
@@ -7353,10 +7404,11 @@ function rdModelMenuRender() {
|
|
|
7353
7404
|
if (!rdMenuProv) {
|
|
7354
7405
|
menu.innerHTML =
|
|
7355
7406
|
'<button type="button" class="type-item" data-p="" data-m=""><span class="ti-body"><span class="ti-name">' +
|
|
7356
|
-
t("自动推荐") + "</span></span>" + (!rdModelPv ? chk : "") + "</button>" +
|
|
7407
|
+
t("自动推荐") + "</span></span>" + (!rdModelPv && !rdModelAgent ? chk : "") + "</button>" +
|
|
7357
7408
|
directProviders().map((p) =>
|
|
7358
7409
|
'<button type="button" class="type-item" data-p="' + esc(p.id) + '"><span class="ti-body"><span class="ti-name">' +
|
|
7359
|
-
esc(p.name || p.id) + "</span></span>" + (rdModelPv === p.id ? chk : arr) + "</button>").join("") +
|
|
7410
|
+
esc(p.name || p.id) + "</span></span>" + (rdModelPv === p.id && !rdModelAgent ? chk : arr) + "</button>").join("") +
|
|
7411
|
+
cliMenuSectionHtml(rdModelAgent) +
|
|
7360
7412
|
'<div class="type-menu-foot"><button type="button" class="type-item" data-manage="1"><span class="ti-body"><span class="ti-name">' +
|
|
7361
7413
|
t("管理模型…") + "</span></span></button></div>";
|
|
7362
7414
|
} else {
|
|
@@ -7389,13 +7441,21 @@ window.toggleRdModelMenu = toggleRdModelMenu;
|
|
|
7389
7441
|
menu.dataset.rdBound = "1";
|
|
7390
7442
|
menu.addEventListener("click", (e) => {
|
|
7391
7443
|
e.stopPropagation(); // 同 cmp-model-menu:防「点外面收起」误判(DOM 重写竞态)
|
|
7392
|
-
const b = e.target.closest("[data-p],[data-back],[data-manage]");
|
|
7444
|
+
const b = e.target.closest("[data-p],[data-back],[data-manage],[data-cli]");
|
|
7393
7445
|
if (!b) return;
|
|
7394
7446
|
if (b.dataset.manage) { toggleRdModelMenu(false); switchTab("models"); return; }
|
|
7395
7447
|
if (b.dataset.back) { rdMenuProv = ""; rdModelMenuRender(); return; }
|
|
7448
|
+
if (b.dataset.cli !== undefined) {
|
|
7449
|
+
rdModelAgent = b.dataset.cli;
|
|
7450
|
+
rdModelFaceSync();
|
|
7451
|
+
toggleRdModelMenu(false);
|
|
7452
|
+
rdPrefsPush("model");
|
|
7453
|
+
return;
|
|
7454
|
+
}
|
|
7396
7455
|
if (b.dataset.m === undefined) { rdMenuProv = b.dataset.p; rdModelMenuRender(); return; }
|
|
7397
7456
|
rdModelPv = b.dataset.p;
|
|
7398
7457
|
rdModelMv = b.dataset.m;
|
|
7458
|
+
rdModelAgent = ""; // 选定模型/自动推荐:CLI 指定随之清除(互斥)
|
|
7399
7459
|
rdModelFaceSync();
|
|
7400
7460
|
toggleRdModelMenu(false);
|
|
7401
7461
|
rdPrefsPush("model");
|
|
@@ -7420,6 +7480,7 @@ async function rdPrefsPush(what) {
|
|
|
7420
7480
|
if (what === "model") {
|
|
7421
7481
|
patch.direct_provider_id = rdModelPv;
|
|
7422
7482
|
patch.direct_model = rdModelMv;
|
|
7483
|
+
patch.direct_agent = rdModelAgent;
|
|
7423
7484
|
}
|
|
7424
7485
|
try {
|
|
7425
7486
|
await api("/api/tasks/" + encodeURIComponent(task.id) + "/params", {
|
|
@@ -8884,6 +8945,8 @@ function renderSkills() {
|
|
|
8884
8945
|
'<span class="tag">' + esc(x.scope === "*" ? t("通用") : x.scope) + "</span>" +
|
|
8885
8946
|
(x.seen > 1 ? '<span class="tag">' + t("出现 ") + x.seen + t(" 次") + "</span>" : "") +
|
|
8886
8947
|
(x.hits ? '<span class="tag">' + t("已注入 ") + x.hits + t(" 次") + "</span>" : "") +
|
|
8948
|
+
(x.won > 0 ? '<span class="tag ok" title="' + t("注入后任务过审的次数(真实帮上忙)") + '">' + t("有效 ") + x.won + "</span>" : "") +
|
|
8949
|
+
(x.lost > 0 ? '<span class="tag" style="color:var(--bad)" title="' + t("注入后任务仍失败的次数(没防住)") + '">' + t("失守 ") + x.lost + "</span>" : "") +
|
|
8887
8950
|
(x.enabled === false ? '<span class="tag">' + t("已停用") + "</span>" : "") + "</div>" +
|
|
8888
8951
|
'<div class="note">' + esc(t(x.content)) + "</div>" +
|
|
8889
8952
|
'<div class="ops">' +
|
|
@@ -9115,6 +9178,11 @@ function autoPrefTags(tsk) {
|
|
|
9115
9178
|
if (tsk.thinking && tsk.thinking !== "standard" && TH[tsk.thinking]) {
|
|
9116
9179
|
out.push(t("思考程度") + t(":") + t(TH[tsk.thinking]));
|
|
9117
9180
|
}
|
|
9181
|
+
if (tsk.direct_agent) {
|
|
9182
|
+
const a = realCliAgents().find((x) => x.id === tsk.direct_agent) ||
|
|
9183
|
+
((S.state && S.state.agents) || []).find((x) => x.id === tsk.direct_agent);
|
|
9184
|
+
out.push(t("执行 CLI") + t(":") + (a ? (a.label || a.id) : tsk.direct_agent));
|
|
9185
|
+
}
|
|
9118
9186
|
if (tsk.direct_provider_id) {
|
|
9119
9187
|
const p = directProviders().find((x) => x.id === tsk.direct_provider_id);
|
|
9120
9188
|
out.push(t("对话模型") + t(":") + (p ? (p.name || p.id) : tsk.direct_provider_id) +
|
package/app/ui/i18n.js
CHANGED
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
"对话厂商": "Chat provider",
|
|
43
43
|
"对话模型": "Chat model",
|
|
44
44
|
"随厂商推荐": "Provider default",
|
|
45
|
+
"指定 CLI": "Run via CLI",
|
|
46
|
+
"执行 CLI": "Agent CLI",
|
|
47
|
+
"执行 CLI:": "Agent CLI: ",
|
|
45
48
|
"只对本次对话生效,不会修改全局绑定;指定渠道不可用时会明确报错。": "Applies only to this chat and does not change global bindings. An unavailable selected route returns an explicit error.",
|
|
46
49
|
"推荐任务类型": "Recommended task type",
|
|
47
50
|
"切换类型": "Switch type",
|
|
@@ -869,6 +872,10 @@
|
|
|
869
872
|
"自定义 CLI": "Custom CLI",
|
|
870
873
|
"已归档": "Archived",
|
|
871
874
|
"已停用": "Disabled",
|
|
875
|
+
"有效 ": "Helped ",
|
|
876
|
+
"失守 ": "Missed ",
|
|
877
|
+
"注入后任务过审的次数(真实帮上忙)": "Times tasks passed review with this lesson injected (actually helped)",
|
|
878
|
+
"注入后任务仍失败的次数(没防住)": "Times tasks still failed with this lesson injected (didn't prevent it)",
|
|
872
879
|
"正在刷新模型列表…": "Refreshing model list…",
|
|
873
880
|
"无任务": "No tasks yet",
|
|
874
881
|
"暂无任务": "No tasks yet",
|
package/app/ui/index.html
CHANGED
|
@@ -424,7 +424,7 @@
|
|
|
424
424
|
<div id="f-direct-model" class="field hidden"><label data-i18n="对话模型(可选)">对话模型(可选)</label><div class="grid-2">
|
|
425
425
|
<select id="f-direct-provider" aria-label="对话厂商" data-i18n-aria="对话厂商"><option data-i18n="自动推荐">自动推荐</option></select>
|
|
426
426
|
<select id="f-direct-model-name" aria-label="对话模型" data-i18n-aria="对话模型"><option data-i18n="随厂商推荐">随厂商推荐</option></select>
|
|
427
|
-
</div><p class="hint" data-i18n="只对本次对话生效,不会修改全局绑定;指定渠道不可用时会明确报错。">只对本次对话生效,不会修改全局绑定;指定渠道不可用时会明确报错。</p></div>
|
|
427
|
+
</div><input type="hidden" id="f-direct-agent" value=""><p class="hint" data-i18n="只对本次对话生效,不会修改全局绑定;指定渠道不可用时会明确报错。">只对本次对话生效,不会修改全局绑定;指定渠道不可用时会明确报错。</p></div>
|
|
428
428
|
<div class="grid-2">
|
|
429
429
|
<div class="field">
|
|
430
430
|
<label data-i18n="继续会话">继续会话</label>
|
package/app/ui/style.css
CHANGED
|
@@ -6076,3 +6076,13 @@ button.request-busy > * { opacity: .18; }
|
|
|
6076
6076
|
display: block; width: 100%; height: min(72vh, 660px);
|
|
6077
6077
|
border: none; border-radius: 10px; background: #fff;
|
|
6078
6078
|
}
|
|
6079
|
+
|
|
6080
|
+
/* 对话模型菜单里的 CLI 分组标题(2026-09-23 手动指定 CLI):与底部
|
|
6081
|
+
* 「管理模型…」同款弱化字重,但不可点,仅作分组界碑。 */
|
|
6082
|
+
.type-menu-cap {
|
|
6083
|
+
padding: 7px 14px 3px;
|
|
6084
|
+
font-size: 11px; font-weight: 600; letter-spacing: .04em;
|
|
6085
|
+
color: var(--muted);
|
|
6086
|
+
border-top: 1px solid var(--line, rgba(127,127,127,.18));
|
|
6087
|
+
margin-top: 4px;
|
|
6088
|
+
}
|
package/package.json
CHANGED