codebee 0.1.0
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/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- package/package.json +39 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""规划器:把用户目标自动拆解为有序子任务。
|
|
3
|
+
|
|
4
|
+
优先用「编排设置」直连 API 的编排者模型(统一规划/管理);未配置或调用
|
|
5
|
+
失败时回落到最强可用 CLI 智能体;再失败退化为单步模板——计划永远可执行,
|
|
6
|
+
不阻塞任务。review 类引擎可让编排者产出写作大纲,拼进起草提示词。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from . import modelhub, runner, skills, usage
|
|
16
|
+
|
|
17
|
+
MAX_SUBTASKS = 4
|
|
18
|
+
DEFAULT_OUTLINE_TIMEOUT = 900 # 8 章大纲 + 经验包注入是重生成任务,300s 实测不够
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _append_log(log_path, text):
|
|
22
|
+
"""向步骤日志追加一段(编排者 API 尝试结果等子进程日志覆盖不到的内容)。
|
|
23
|
+
|
|
24
|
+
编排者直连调用不走 run_process,没有自动落盘;不补写的话运行中步骤
|
|
25
|
+
日志始终为空,失败原因(网关 502/解析失败)对外完全不可见。"""
|
|
26
|
+
if not log_path:
|
|
27
|
+
return
|
|
28
|
+
try:
|
|
29
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
30
|
+
f.write(text if text.endswith("\n") else text + "\n")
|
|
31
|
+
except OSError:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _log_streamer(log_path, min_chars=400, min_secs=0.8):
|
|
36
|
+
"""直连流式增量 → 步骤日志的节流写入器(原样拼接,不额外加换行)。
|
|
37
|
+
|
|
38
|
+
编排者直连生成原本全程黑箱:日志只有一行标题,用户盯着它几分钟以为
|
|
39
|
+
卡死。每个 SSE 分片都开文件写太碎,攒够 min_chars 或超过 min_secs 才
|
|
40
|
+
刷一次;结束时必须调 cb.flush() 补上尾段。无 log_path 时为空操作
|
|
41
|
+
(仍带 flush,调用方无需判空)。"""
|
|
42
|
+
if not log_path:
|
|
43
|
+
def nop(delta):
|
|
44
|
+
pass
|
|
45
|
+
nop.flush = lambda: None
|
|
46
|
+
return nop
|
|
47
|
+
st = {"buf": "", "at": time.time()}
|
|
48
|
+
|
|
49
|
+
def _write():
|
|
50
|
+
if st["buf"]:
|
|
51
|
+
try:
|
|
52
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
53
|
+
f.write(st["buf"])
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
st["buf"] = ""
|
|
57
|
+
st["at"] = time.time()
|
|
58
|
+
|
|
59
|
+
def cb(delta):
|
|
60
|
+
if not delta:
|
|
61
|
+
return
|
|
62
|
+
st["buf"] += delta
|
|
63
|
+
if len(st["buf"]) >= min_chars or time.time() - st["at"] >= min_secs:
|
|
64
|
+
_write()
|
|
65
|
+
|
|
66
|
+
def flush():
|
|
67
|
+
_write()
|
|
68
|
+
cb.flush = flush
|
|
69
|
+
return cb
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _outline_timeout():
|
|
73
|
+
"""大纲/规划的 CLI 兜底超时(秒):env TUTTI_OUTLINE_TIMEOUT(秒)优先,
|
|
74
|
+
其次设置 orchestrator.outline_timeout_s,缺省 900。"""
|
|
75
|
+
try:
|
|
76
|
+
raw = os.environ.get("TUTTI_OUTLINE_TIMEOUT")
|
|
77
|
+
if raw:
|
|
78
|
+
return max(60, min(3600, int(float(raw))))
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
try:
|
|
82
|
+
from .settings_schema import get as ss_get, register_default_namespaces
|
|
83
|
+
register_default_namespaces()
|
|
84
|
+
v = int(ss_get("orchestrator", "outline_timeout_s") or 0)
|
|
85
|
+
if v > 0:
|
|
86
|
+
return v
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
return DEFAULT_OUTLINE_TIMEOUT
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _log_usage(source, role, task, res, agent=None, tool="", model="", provider="",
|
|
93
|
+
provider_id=""):
|
|
94
|
+
"""规划链路的调用入台账(编排者直连 / CLI 规划);失败不影响规划本身。"""
|
|
95
|
+
try:
|
|
96
|
+
usage.record(source=source, task_id=(task or {}).get("id", ""),
|
|
97
|
+
task_type=(task or {}).get("type", ""), role=role,
|
|
98
|
+
agent=(agent or {}).get("id", "") or "orchestrator",
|
|
99
|
+
agent_label=(agent or {}).get("label", "") or "编排者",
|
|
100
|
+
tool=tool or (agent or {}).get("kind", "") or "orchestrator",
|
|
101
|
+
model=model or ((res or {}).get("model") or ""),
|
|
102
|
+
provider=provider, ok=bool((res or {}).get("ok")),
|
|
103
|
+
duration_s=float(((res or {}).get("raw") or {}).get("duration") or 0.0),
|
|
104
|
+
cost_usd=float((res or {}).get("cost_usd") or 0.0),
|
|
105
|
+
usage=(res or {}).get("usage"))
|
|
106
|
+
# 告警模块:编排者直连调用的成功/失败上报(provider_id 供「禁用厂商」定位)
|
|
107
|
+
if provider:
|
|
108
|
+
from . import health
|
|
109
|
+
if (res or {}).get("ok"):
|
|
110
|
+
health.report_success(provider)
|
|
111
|
+
else:
|
|
112
|
+
health.report_failure(provider, (res or {}).get("error") or "",
|
|
113
|
+
model=model, provider_id=provider_id)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
|
|
118
|
+
并判定任务难度。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
119
|
+
{"difficulty": "easy 或 hard", "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令"}]}
|
|
120
|
+
难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
|
|
121
|
+
子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
|
|
122
|
+
|
|
123
|
+
## 开发目标
|
|
124
|
+
__GOAL__
|
|
125
|
+
|
|
126
|
+
## 背景与上下文
|
|
127
|
+
__CONTEXT__
|
|
128
|
+
|
|
129
|
+
## 验收命令(最终必须通过)
|
|
130
|
+
__VERIFY__"""
|
|
131
|
+
|
|
132
|
+
REVIEW_OUTLINE_PROMPT = """你是内容主编。请为下面的创作任务拟一份写作大纲(要点列表,3-8 条),
|
|
133
|
+
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
134
|
+
{"outline": ["要点1", "要点2", ...]}
|
|
135
|
+
|
|
136
|
+
## 创作任务
|
|
137
|
+
__GOAL__
|
|
138
|
+
|
|
139
|
+
## 背景与上下文
|
|
140
|
+
__CONTEXT__"""
|
|
141
|
+
|
|
142
|
+
SERIAL_OUTLINE_PROMPT = """你是网文主编,熟悉签约平台(番茄/七猫/起点)的过稿标准。
|
|
143
|
+
|
|
144
|
+
__SKILLS__
|
|
145
|
+
请为下面的小说目标设计一份连载大纲:共 __N__ 章,每章约 __W__ 字。
|
|
146
|
+
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
147
|
+
{"book_title": "书名", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]}
|
|
148
|
+
硬性要求:
|
|
149
|
+
- 第 1-3 章是黄金三章:第 1 章开篇即冲突+人设立住,第 3 章末留大钩子;
|
|
150
|
+
- 每章有明确冲突与剧情推进,禁止水字数的日常流水账;
|
|
151
|
+
- 结局必须闭环(完本感),主角有成长弧光;
|
|
152
|
+
- 题材健康,无违规内容,符合平台签约调性。
|
|
153
|
+
|
|
154
|
+
## 小说目标
|
|
155
|
+
__GOAL__
|
|
156
|
+
|
|
157
|
+
## 背景与上下文
|
|
158
|
+
__CONTEXT__"""
|
|
159
|
+
|
|
160
|
+
SERIAL_CONTINUE_OUTLINE_PROMPT = """你是网文主编,熟悉签约平台(番茄/七猫/起点)的过稿标准。
|
|
161
|
+
|
|
162
|
+
__SKILLS__
|
|
163
|
+
这是一部长篇连载的续写:全书已完成前 __DONE__ 章,现在请规划第 __START__–__END__ 章
|
|
164
|
+
(本批共 __N__ 章,每章约 __W__ 字)。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
165
|
+
{"book_title": "书名(与前文保持一致)", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]}
|
|
166
|
+
硬性要求:
|
|
167
|
+
- 第 1 章直接衔接前文(见下方前情),不得跳线、不得重启设定、不得复述前文;
|
|
168
|
+
- 主线沿既有脉络推进,新冲突尽量从已埋伏笔中生长,人物性格与前文一致;
|
|
169
|
+
- 每章有明确冲突与剧情推进,禁止水字数的日常流水账;
|
|
170
|
+
- 题材健康,无违规内容,符合平台签约调性。
|
|
171
|
+
|
|
172
|
+
## 前情大纲(已完成章节,章号为全书章号)
|
|
173
|
+
__PREV_OUTLINE__
|
|
174
|
+
|
|
175
|
+
## 最新一章结尾(衔接锚点)
|
|
176
|
+
__PREV_TAIL__
|
|
177
|
+
|
|
178
|
+
## 小说目标
|
|
179
|
+
__GOAL__
|
|
180
|
+
|
|
181
|
+
## 背景与上下文
|
|
182
|
+
__CONTEXT__"""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _norm_chapters(data, n):
|
|
186
|
+
"""规范化连载大纲输出;不合规返回 None。"""
|
|
187
|
+
if not isinstance(data, dict):
|
|
188
|
+
return None
|
|
189
|
+
chs = data.get("chapters")
|
|
190
|
+
if not isinstance(chs, list) or not chs:
|
|
191
|
+
return None
|
|
192
|
+
out = []
|
|
193
|
+
for c in chs[:n]:
|
|
194
|
+
if not isinstance(c, dict):
|
|
195
|
+
continue
|
|
196
|
+
title = str(c.get("title") or "").strip()
|
|
197
|
+
beats = str(c.get("beats") or "").strip()
|
|
198
|
+
if not title:
|
|
199
|
+
continue
|
|
200
|
+
out.append({"title": title[:60], "beats": beats[:500],
|
|
201
|
+
"hook": str(c.get("hook") or "").strip()[:200]})
|
|
202
|
+
if len(out) < min(n, max(2, n // 2)): # 至少给出半数章的大纲,否则视为失败(n=1 时至少 1 章)
|
|
203
|
+
return None
|
|
204
|
+
while len(out) < n: # 缺的章补模板位
|
|
205
|
+
out.append({"title": "第 %d 章" % (len(out) + 1), "beats": "按全书目标推进剧情",
|
|
206
|
+
"hook": ""})
|
|
207
|
+
return {"book_title": str(data.get("book_title") or "").strip()[:40], "chapters": out[:n]}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _prev_serial_story(task):
|
|
211
|
+
"""续写大纲的前情素材:沿 serial.continues 链收集已完成各章大纲(标全书章号)
|
|
212
|
+
+ 最新一章结尾(衔接锚点)+ 既有书名。返回 (前情文本, 已完成章数, 书名, 最新章结尾)。"""
|
|
213
|
+
from . import store # 惰性导入:store 不依赖 planner,避免测试环境导入顺序问题
|
|
214
|
+
chain, seen, cur = [], set(), task
|
|
215
|
+
while len(chain) < 10:
|
|
216
|
+
cont = str((cur.get("serial") or {}).get("continues") or "")
|
|
217
|
+
prev = store.get_task(cont) if cont else None
|
|
218
|
+
if not prev or prev["id"] in seen:
|
|
219
|
+
break
|
|
220
|
+
seen.add(prev["id"])
|
|
221
|
+
chain.append(prev)
|
|
222
|
+
cur = prev
|
|
223
|
+
lines, book_title = [], ""
|
|
224
|
+
for prev in reversed(chain): # 旧 → 新,前情按章号顺序铺开
|
|
225
|
+
try:
|
|
226
|
+
ps = int((prev.get("serial") or {}).get("start_chapter") or 1)
|
|
227
|
+
except Exception:
|
|
228
|
+
ps = 1
|
|
229
|
+
outline = None
|
|
230
|
+
for r in store.task_runs(prev["id"]):
|
|
231
|
+
o = r.get("outline")
|
|
232
|
+
if o and o.get("chapters") and not o.get("degraded"):
|
|
233
|
+
outline = o
|
|
234
|
+
break
|
|
235
|
+
if not outline:
|
|
236
|
+
continue
|
|
237
|
+
book_title = book_title or str(outline.get("book_title") or "")
|
|
238
|
+
for k, c in enumerate(outline["chapters"]):
|
|
239
|
+
lines.append("第 %d 章《%s》:%s" % (ps + k, c.get("title", ""),
|
|
240
|
+
str(c.get("beats") or "")[:120]))
|
|
241
|
+
# 衔接锚点:工作目录里章号最大的章节文件结尾(续写批次共用同一目录)
|
|
242
|
+
tail, best_i = "", 0
|
|
243
|
+
try:
|
|
244
|
+
from pathlib import Path
|
|
245
|
+
for p in Path(task.get("workdir") or "").glob("chapter-*.md"):
|
|
246
|
+
m = re.match(r"^chapter-(\d{1,4})\.md$", p.name)
|
|
247
|
+
if m and int(m.group(1)) > best_i:
|
|
248
|
+
best, best_i = p, int(m.group(1))
|
|
249
|
+
if best_i:
|
|
250
|
+
# GBK 兼容:CLI 子代理在中文 Windows 上可能把章稿落成 GBK
|
|
251
|
+
b = best.read_bytes()
|
|
252
|
+
try:
|
|
253
|
+
tail = b.decode("utf-8")[-500:].strip()
|
|
254
|
+
except UnicodeDecodeError:
|
|
255
|
+
try:
|
|
256
|
+
tail = b.decode("gbk")[-500:].strip()
|
|
257
|
+
except UnicodeDecodeError:
|
|
258
|
+
tail = b.decode("utf-8", "replace")[-500:].strip()
|
|
259
|
+
except OSError:
|
|
260
|
+
pass
|
|
261
|
+
return "\n".join(lines), max(best_i, 0), book_title, tail
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None):
|
|
265
|
+
"""连载大纲:编排者 API 优先 → 作者 CLI → 模板。返回 {book_title, chapters:[{title,beats,hook}]}。
|
|
266
|
+
|
|
267
|
+
续写批次(serial.start_chapter > 1):改用续写大纲提示词,注入前情大纲与
|
|
268
|
+
最新一章结尾;书名沿用前文,保证跨批次剧情/设定衔接。
|
|
269
|
+
|
|
270
|
+
log_path:步骤日志绝对路径。编排者直连调用不经 run_process,需显式补写
|
|
271
|
+
尝试结果,否则运行中点开步骤永远显示(无输出)、失败原因不可见。"""
|
|
272
|
+
serial = task.get("serial") or {}
|
|
273
|
+
n = int(serial.get("chapters") or 8)
|
|
274
|
+
wpc = int(serial.get("words_per_chapter") or 2500)
|
|
275
|
+
start = int(serial.get("start_chapter") or 1)
|
|
276
|
+
sk_block, _ = skills.block_for(task)
|
|
277
|
+
prev_title = ""
|
|
278
|
+
if start > 1:
|
|
279
|
+
prev_lines, done, prev_title, prev_tail = _prev_serial_story(task)
|
|
280
|
+
done = max(done, start - 1)
|
|
281
|
+
prompt = (SERIAL_CONTINUE_OUTLINE_PROMPT
|
|
282
|
+
.replace("__SKILLS__", sk_block)
|
|
283
|
+
.replace("__DONE__", str(done))
|
|
284
|
+
.replace("__START__", str(start))
|
|
285
|
+
.replace("__END__", str(start + n - 1))
|
|
286
|
+
.replace("__N__", str(n)).replace("__W__", str(wpc))
|
|
287
|
+
.replace("__PREV_OUTLINE__", prev_lines or "(无大纲记录,请依据下方最新一章结尾与小说目标衔接)")
|
|
288
|
+
.replace("__PREV_TAIL__", prev_tail or "(无)")
|
|
289
|
+
.replace("__GOAL__", task["goal"])
|
|
290
|
+
.replace("__CONTEXT__", task.get("context") or "(无)"))
|
|
291
|
+
else:
|
|
292
|
+
prompt = (SERIAL_OUTLINE_PROMPT.replace("__SKILLS__", sk_block)
|
|
293
|
+
.replace("__N__", str(n)).replace("__W__", str(wpc))
|
|
294
|
+
.replace("__GOAL__", task["goal"])
|
|
295
|
+
.replace("__CONTEXT__", task.get("context") or "(无)"))
|
|
296
|
+
# 续写批次打上全书章号标记;书名缺省时沿用前文
|
|
297
|
+
def _mark(o):
|
|
298
|
+
if o and start > 1:
|
|
299
|
+
o["start_chapter"] = start
|
|
300
|
+
if not o.get("book_title"):
|
|
301
|
+
o["book_title"] = prev_title
|
|
302
|
+
return o
|
|
303
|
+
|
|
304
|
+
orch = _orchestrator()
|
|
305
|
+
orch_errors = [] # 每次编排者尝试的真实失败原因,落步骤日志 + degraded_reason
|
|
306
|
+
if orch:
|
|
307
|
+
prov, model = orch
|
|
308
|
+
label = "%s · %s" % (prov.get("name", prov["id"]), model)
|
|
309
|
+
_append_log(log_path, "===== 编排者大纲(%s)=====" % label)
|
|
310
|
+
# 网关 502/503 是常见瞬时故障,编排者重试一次再放弃(实测公司网关连续 8h 502)
|
|
311
|
+
for _attempt in (1, 2):
|
|
312
|
+
# glm-5.3 等推理模型的"思考"就吃掉数千 token:max_tokens 给足,
|
|
313
|
+
# 否则 stop_reason=max_tokens、正文为空(实测 2048 全被思考吞掉)
|
|
314
|
+
# 流式回调把增量实时写进步骤日志,直连生成不再是一行标题的黑箱
|
|
315
|
+
cb = _log_streamer(log_path)
|
|
316
|
+
res = modelhub.chat(prov["id"], model, prompt,
|
|
317
|
+
max_tokens=16000, timeout=300, on_delta=cb)
|
|
318
|
+
cb.flush()
|
|
319
|
+
_log_usage("outline", "outline", task, res, model=model,
|
|
320
|
+
provider=prov.get("name", prov.get("id", "")),
|
|
321
|
+
provider_id=prov.get("id", ""))
|
|
322
|
+
if res["ok"]:
|
|
323
|
+
_append_log(log_path, "尝试 %d:返回 %d tokens,解析 JSON 中…"
|
|
324
|
+
% (_attempt, res.get("tokens") or 0))
|
|
325
|
+
else:
|
|
326
|
+
_append_log(log_path, "尝试 %d 失败:%s"
|
|
327
|
+
% (_attempt, (res.get("error") or "未知错误")[:300]))
|
|
328
|
+
orch_errors.append(str(res.get("error") or "返回内容无法解析为大纲"))
|
|
329
|
+
data = runner.extract_json(res.get("text") or "") if res["ok"] else None
|
|
330
|
+
outline = _norm_chapters(data, n)
|
|
331
|
+
if outline:
|
|
332
|
+
outline["source"] = "编排者(%s)" % label
|
|
333
|
+
return _mark(outline)
|
|
334
|
+
_append_log(log_path, "编排者两次尝试均未产出可用大纲,回退作者 CLI…")
|
|
335
|
+
reason_tail = (";".join(dict.fromkeys(orch_errors))[:200]) if orch_errors \
|
|
336
|
+
else "编排者未配置/不可用"
|
|
337
|
+
|
|
338
|
+
if author_agent and author_agent.get("mode") == "real":
|
|
339
|
+
# 8 章大纲 + 经验包注入是重生成任务,300s 实测不够(claude CLI 必超时);
|
|
340
|
+
# 超时可经 env TUTTI_OUTLINE_TIMEOUT 或设置 orchestrator.outline_timeout_s 调整
|
|
341
|
+
_append_log(log_path, "===== 作者 CLI(%s)=====" % author_agent.get("id", "?"))
|
|
342
|
+
res = runner.run_agent(modelhub.bind_agent(author_agent), prompt,
|
|
343
|
+
workdir=workdir or task.get("workdir"), readonly=True,
|
|
344
|
+
timeout=_outline_timeout(), cancel_event=ev,
|
|
345
|
+
log_path=log_path)
|
|
346
|
+
_log_usage("outline", "outline", task, res, agent=author_agent)
|
|
347
|
+
if not res["ok"]:
|
|
348
|
+
_append_log(log_path, "作者 CLI 失败:%s" % (res.get("error") or "")[:300])
|
|
349
|
+
reason_tail = (res.get("error") or reason_tail)[:200]
|
|
350
|
+
outline = _norm_chapters(runner.extract_json(res.get("text") or ""), n)
|
|
351
|
+
if outline:
|
|
352
|
+
outline["source"] = "llm(%s)" % author_agent["id"]
|
|
353
|
+
return _mark(outline)
|
|
354
|
+
elif author_agent and author_agent.get("mode") == "mock":
|
|
355
|
+
# mock:确定性模板大纲
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
# 兜底模板只有章号、没有任何情节设计,据此写出的全书等于空转。
|
|
359
|
+
# 真实任务标记 degraded 让上层中止并等续跑重试;mock 测试按确定性模板继续。
|
|
360
|
+
chapters = [{"title": "第 %d 章" % (start + i), "beats": "按全书目标推进剧情,保持冲突与钩子",
|
|
361
|
+
"hook": ""} for i in range(n)]
|
|
362
|
+
out = {"book_title": prev_title, "chapters": chapters, "source": "template"}
|
|
363
|
+
if author_agent and author_agent.get("mode") != "mock":
|
|
364
|
+
out["degraded"] = True
|
|
365
|
+
out["degraded_reason"] = "编排者/作者模型均未返回可用大纲(%s)" % reason_tail
|
|
366
|
+
return _mark(out)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _norm_subtasks(data):
|
|
370
|
+
"""规范化 LLM 计划输出;不合规返回 None。"""
|
|
371
|
+
if not isinstance(data, dict):
|
|
372
|
+
return None
|
|
373
|
+
subs = data.get("subtasks")
|
|
374
|
+
if not isinstance(subs, list) or not subs:
|
|
375
|
+
return None
|
|
376
|
+
steps = []
|
|
377
|
+
for s in subs[:MAX_SUBTASKS]:
|
|
378
|
+
if not isinstance(s, dict):
|
|
379
|
+
continue
|
|
380
|
+
title = str(s.get("title") or "").strip()
|
|
381
|
+
detail = str(s.get("detail") or "").strip()
|
|
382
|
+
if not title:
|
|
383
|
+
continue
|
|
384
|
+
steps.append({"title": title[:60], "detail": detail[:1500]})
|
|
385
|
+
return steps or None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _plan_difficulty(data):
|
|
389
|
+
d = str((data or {}).get("difficulty") or "").lower()
|
|
390
|
+
return d if d in ("easy", "hard") else None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _orchestrator():
|
|
394
|
+
"""编排者可用时返回 (provider, model),否则 None。"""
|
|
395
|
+
try:
|
|
396
|
+
return modelhub.resolve_orchestrator()
|
|
397
|
+
except Exception:
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=None):
|
|
402
|
+
"""代码任务计划:编排者 API 优先 → CLI 智能体 → 单步模板。"""
|
|
403
|
+
orch = _orchestrator()
|
|
404
|
+
if orch:
|
|
405
|
+
plan = _orch_code_plan(task, orch[0], orch[1], log_path=log_path)
|
|
406
|
+
if plan:
|
|
407
|
+
return plan
|
|
408
|
+
if planner_agent is None:
|
|
409
|
+
return _fallback_code_plan(task, "(无可用智能体)")
|
|
410
|
+
if planner_agent.get("mode") == "mock":
|
|
411
|
+
return _fallback_code_plan(task, "mock 模式:单步模板")
|
|
412
|
+
prompt = (CODE_PLAN_PROMPT.replace("__N__", str(MAX_SUBTASKS))
|
|
413
|
+
.replace("__GOAL__", task["goal"])
|
|
414
|
+
.replace("__CONTEXT__", task.get("context") or "(无)")
|
|
415
|
+
.replace("__VERIFY__", task.get("verify_command") or "(未配置)"))
|
|
416
|
+
res = runner.run_agent(planner_agent, prompt, workdir=workdir, readonly=True,
|
|
417
|
+
timeout=300, cancel_event=ev, resume=resume,
|
|
418
|
+
log_path=log_path)
|
|
419
|
+
_log_usage("plan", "plan", task, res, agent=planner_agent)
|
|
420
|
+
data = runner.extract_json(res.get("text") or "")
|
|
421
|
+
steps = _norm_subtasks(data)
|
|
422
|
+
if steps:
|
|
423
|
+
return {"source": "llm(%s)" % planner_agent["id"], "steps": steps,
|
|
424
|
+
"difficulty": _plan_difficulty(data)}
|
|
425
|
+
return _fallback_code_plan(task, "LLM 计划解析失败,退化为单步模板")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _fallback_code_plan(task, note):
|
|
429
|
+
return {"source": "template", "note": note,
|
|
430
|
+
"steps": [{"title": "实现任务", "detail": task["goal"]}]}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _orch_code_plan(task, prov, model, log_path=None):
|
|
434
|
+
cb = _log_streamer(log_path)
|
|
435
|
+
res = modelhub.chat(prov["id"], model,
|
|
436
|
+
(CODE_PLAN_PROMPT.replace("__N__", str(MAX_SUBTASKS))
|
|
437
|
+
.replace("__GOAL__", task["goal"])
|
|
438
|
+
.replace("__CONTEXT__", task.get("context") or "(无)")
|
|
439
|
+
.replace("__VERIFY__", task.get("verify_command") or "(未配置)")),
|
|
440
|
+
max_tokens=8000, timeout=300, on_delta=cb)
|
|
441
|
+
cb.flush()
|
|
442
|
+
_log_usage("plan", "plan", task, res, model=model,
|
|
443
|
+
provider=prov.get("name", prov.get("id", "")))
|
|
444
|
+
if not res["ok"]:
|
|
445
|
+
_append_log(log_path, "编排者计划(%s · %s)失败:%s" % (
|
|
446
|
+
prov.get("name", prov["id"]), model, (res.get("error") or "未知错误")[:300]))
|
|
447
|
+
return None
|
|
448
|
+
data = runner.extract_json(res.get("text") or "")
|
|
449
|
+
steps = _norm_subtasks(data)
|
|
450
|
+
if not steps:
|
|
451
|
+
_append_log(log_path, "编排者计划返回内容无法解析为子任务,回退 CLI")
|
|
452
|
+
return None
|
|
453
|
+
return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
|
|
454
|
+
"steps": steps, "difficulty": _plan_difficulty(data)}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def make_review_outline(task):
|
|
458
|
+
"""review 类任务:编排者产出写作大纲(失败返回 None,起草退回无大纲)。"""
|
|
459
|
+
orch = _orchestrator()
|
|
460
|
+
if not orch:
|
|
461
|
+
return None
|
|
462
|
+
prov, model = orch
|
|
463
|
+
res = modelhub.chat(prov["id"], model,
|
|
464
|
+
(REVIEW_OUTLINE_PROMPT
|
|
465
|
+
.replace("__GOAL__", task["goal"])
|
|
466
|
+
.replace("__CONTEXT__", task.get("context") or "(无)")),
|
|
467
|
+
max_tokens=8000, timeout=300)
|
|
468
|
+
_log_usage("outline", "outline", task, res, model=model,
|
|
469
|
+
provider=prov.get("name", prov.get("id", "")))
|
|
470
|
+
if not res["ok"]:
|
|
471
|
+
return None
|
|
472
|
+
data = runner.extract_json(res.get("text") or "")
|
|
473
|
+
outline = data.get("outline") if isinstance(data, dict) else None
|
|
474
|
+
if not isinstance(outline, list):
|
|
475
|
+
return None
|
|
476
|
+
items = [str(x).strip()[:120] for x in outline if str(x).strip()][:8]
|
|
477
|
+
return {"source": "编排者(%s · %s)" % (prov.get("name", prov["id"]), model),
|
|
478
|
+
"items": items} if items else None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def make_novel_plan(task, author, critics):
|
|
482
|
+
dims = "、".join(task.get("rubric") or ["情节", "人物", "文笔", "节奏", "吸引力"])
|
|
483
|
+
return {
|
|
484
|
+
"source": "template",
|
|
485
|
+
"steps": [
|
|
486
|
+
{"title": "起草", "detail": "作者 %s 按目标撰写稿件" % author.get("label", author["id"])},
|
|
487
|
+
{"title": "多维度评审", "detail": "评审组 %s 按 %s 打分,每维度阈值 %.1f"
|
|
488
|
+
% ("、".join(a.get("label", a["id"]) for a in critics), dims, task.get("threshold", 7.0))},
|
|
489
|
+
{"title": "修订循环", "detail": "任一维度低于阈值 → 汇总 major 意见回炉,至多 %d 轮"
|
|
490
|
+
% task.get("rounds", 2)},
|
|
491
|
+
{"title": "发布门禁", "detail": "所有维度达标才标记可发布,输出评审报告"},
|
|
492
|
+
],
|
|
493
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""编排注册表:catalog(装了什么)× 用户偏好(启用谁)→ 可编排智能体。
|
|
3
|
+
|
|
4
|
+
data/orchestration.json 结构:
|
|
5
|
+
{"codex-cli": {"enabled": true}, ...}
|
|
6
|
+
|
|
7
|
+
运行时用哪个模型(含主模型/降级备选链、供应商注入、难度路由)统一在
|
|
8
|
+
「CLI 绑定」页配置,存 modelhub 的 data/models.json bindings。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
from . import paths
|
|
16
|
+
|
|
17
|
+
MOCK_AGENTS = [
|
|
18
|
+
{"id": "mock-a", "label": "演示智能体 A(mock)", "kind": "mock", "mode": "mock", "command": ""},
|
|
19
|
+
{"id": "mock-b", "label": "演示智能体 B(mock)", "kind": "mock", "mode": "mock", "command": ""},
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
_LOCK = threading.RLock()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_enabled():
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(paths.ENABLED_FILE.read_text(encoding="utf-8"))
|
|
28
|
+
except Exception:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_enabled(state):
|
|
33
|
+
with _LOCK:
|
|
34
|
+
paths.ensure_dirs()
|
|
35
|
+
paths.ENABLED_FILE.write_text(
|
|
36
|
+
json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_preference(agent_id, enabled=None, model=None, models=None):
|
|
40
|
+
"""目前只管「参与编排」开关;model/models 是旧参数,静默忽略
|
|
41
|
+
(模型链已并入 modelhub bindings,保留签名兼容旧调用方)。"""
|
|
42
|
+
with _LOCK:
|
|
43
|
+
state = load_enabled()
|
|
44
|
+
pref = state.get(agent_id) or {}
|
|
45
|
+
pref.pop("model", None)
|
|
46
|
+
pref.pop("models", None) # 顺手清掉历史遗留字段
|
|
47
|
+
if enabled is not None:
|
|
48
|
+
pref["enabled"] = bool(enabled)
|
|
49
|
+
state[agent_id] = pref
|
|
50
|
+
save_enabled(state)
|
|
51
|
+
return pref
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_agent(entry):
|
|
55
|
+
"""catalog 条目 → 运行时智能体字典(resume_argv_template 透传给 runner)。"""
|
|
56
|
+
orch = entry.get("orch") or {}
|
|
57
|
+
return {
|
|
58
|
+
"id": entry["id"],
|
|
59
|
+
"label": entry.get("name", entry["id"]),
|
|
60
|
+
"kind": orch.get("kind", "generic"),
|
|
61
|
+
"command": orch.get("command") or (entry.get("detect") or {}).get("cli") or entry["id"],
|
|
62
|
+
"mode": "real",
|
|
63
|
+
"env": orch.get("env") or {},
|
|
64
|
+
"argv_template": orch.get("argv_template"),
|
|
65
|
+
"resume_argv_template": orch.get("resume_argv_template"),
|
|
66
|
+
# 小时级 token 配额(可选,0/缺省=不限):路由时对本小时用量超标的
|
|
67
|
+
# 智能体降权(munder-difflin 式配额感知),订阅型 CLI 不至于被单任务打爆
|
|
68
|
+
"quota_tokens_per_hour": int(orch.get("quota_tokens_per_hour") or 0),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def effective_agents(catalog_entries, detected):
|
|
73
|
+
"""生成当前可参与编排的智能体列表(真实已装+启用,外加内置 mock)。"""
|
|
74
|
+
enabled = load_enabled()
|
|
75
|
+
out = []
|
|
76
|
+
for entry in catalog_entries:
|
|
77
|
+
orch = entry.get("orch")
|
|
78
|
+
if not orch or not orch.get("kind"):
|
|
79
|
+
continue
|
|
80
|
+
det = (detected or {}).get(entry.get("id")) or {}
|
|
81
|
+
if not det.get("installed"):
|
|
82
|
+
continue
|
|
83
|
+
pref = enabled.get(entry.get("id")) or {}
|
|
84
|
+
if not pref.get("enabled", entry.get("default_enabled", False)):
|
|
85
|
+
continue
|
|
86
|
+
out.append(_build_agent(entry))
|
|
87
|
+
out.extend([dict(m) for m in MOCK_AGENTS])
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def installed_agent(entry_id, catalog_entries, detected):
|
|
92
|
+
"""按 id 构建「已安装」的智能体,无视编排启用开关。
|
|
93
|
+
|
|
94
|
+
续会话是用户对该 CLI 的显式指定,不依赖其是否参与自动路由;
|
|
95
|
+
未安装 / 无编排配置 / id 不存在时返回 None。
|
|
96
|
+
"""
|
|
97
|
+
for entry in catalog_entries:
|
|
98
|
+
if entry.get("id") != entry_id:
|
|
99
|
+
continue
|
|
100
|
+
if not (entry.get("orch") or {}).get("kind"):
|
|
101
|
+
return None
|
|
102
|
+
if not ((detected or {}).get(entry_id) or {}).get("installed"):
|
|
103
|
+
return None
|
|
104
|
+
return _build_agent(entry)
|
|
105
|
+
return None
|