codebee 0.1.53 → 0.1.55
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 +17 -0
- package/README.md +3 -4
- package/app/core/board.py +209 -0
- package/app/core/builtin_agent.py +67 -26
- package/app/core/chaptersafe.py +32 -0
- package/app/core/jobs.py +22 -3
- package/app/core/knowledge.py +21 -4
- package/app/core/modelhub.py +33 -4
- package/app/core/pipeline.py +238 -66
- package/app/core/planner.py +27 -13
- package/app/core/runner.py +356 -142
- package/app/core/share_page.py +4 -1
- package/app/core/store.py +68 -28
- package/app/core/zentao.py +26 -6
- package/app/main.py +4 -1
- package/app/ui/app.js +13416 -13324
- package/app/ui/board.css +390 -0
- package/app/ui/board.html +162 -0
- package/app/ui/board.js +305 -0
- package/app/ui/i18n.js +2698 -2635
- package/app/ui/index.html +4 -0
- package/app/ui/style.css +17 -0
- package/package.json +1 -1
package/app/core/modelhub.py
CHANGED
|
@@ -2156,11 +2156,14 @@ def _entry_endpoint(prov, allowed):
|
|
|
2156
2156
|
"""
|
|
2157
2157
|
proto = prov.get("protocol")
|
|
2158
2158
|
if proto in allowed:
|
|
2159
|
-
|
|
2159
|
+
base = prov.get("base_url")
|
|
2160
|
+
if not _wire_endpoint_compatible(proto, base):
|
|
2161
|
+
return None
|
|
2162
|
+
return proto, base, prov.get("wire_api", "responses")
|
|
2160
2163
|
caps = prov.get("wire_caps") or {}
|
|
2161
2164
|
for p in allowed:
|
|
2162
2165
|
cap = caps.get(p) or {}
|
|
2163
|
-
if cap.get("base"):
|
|
2166
|
+
if cap.get("base") and _wire_endpoint_compatible(p, cap["base"]):
|
|
2164
2167
|
return p, cap["base"], cap.get("wire_api") or "responses"
|
|
2165
2168
|
return None
|
|
2166
2169
|
|
|
@@ -2172,10 +2175,12 @@ def _protocol_candidates(prov):
|
|
|
2172
2175
|
调用方逐个试、全失败才报错——不猜一条去发请求。"""
|
|
2173
2176
|
proto = (prov or {}).get("protocol")
|
|
2174
2177
|
if proto in _PROTOCOLS:
|
|
2175
|
-
|
|
2178
|
+
base = (prov or {}).get("base_url") or ""
|
|
2179
|
+
return ([(proto, base)] if _wire_endpoint_compatible(proto, base) else [])
|
|
2176
2180
|
caps = (prov or {}).get("wire_caps") or {}
|
|
2177
2181
|
return [(p, caps[p]["base"]) for p in _WIRE_PREFERENCE
|
|
2178
|
-
if (caps.get(p) or {}).get("base")
|
|
2182
|
+
if (caps.get(p) or {}).get("base")
|
|
2183
|
+
and _wire_endpoint_compatible(p, (caps.get(p) or {}).get("base"))]
|
|
2179
2184
|
|
|
2180
2185
|
|
|
2181
2186
|
def _ensure_wire_candidates(provider_id, prov, model_name=""):
|
|
@@ -2195,6 +2200,26 @@ def _ensure_wire_candidates(provider_id, prov, model_name=""):
|
|
|
2195
2200
|
return (_protocol_candidates(prov2) if prov2 else []), (note or "")
|
|
2196
2201
|
|
|
2197
2202
|
|
|
2203
|
+
def _wire_endpoint_compatible(proto, base_url):
|
|
2204
|
+
"""Reject an endpoint whose URL path contradicts the selected wire.
|
|
2205
|
+
|
|
2206
|
+
Some providers expose an Anthropic API under a path containing ``anthropic``
|
|
2207
|
+
and may have stale ``wire_caps`` from an earlier probe that recorded that
|
|
2208
|
+
same URL as OpenAI/Responses. Codex then retries forever because it sends
|
|
2209
|
+
Responses events to the Anthropic route. A path marker is a conservative
|
|
2210
|
+
local guard; normal shared roots such as ``https://host/v1`` remain valid
|
|
2211
|
+
for both protocols.
|
|
2212
|
+
"""
|
|
2213
|
+
if proto != "openai" or not base_url:
|
|
2214
|
+
return True
|
|
2215
|
+
try:
|
|
2216
|
+
import urllib.parse
|
|
2217
|
+
path = (urllib.parse.urlsplit(str(base_url)).path or "").lower()
|
|
2218
|
+
except Exception:
|
|
2219
|
+
return True
|
|
2220
|
+
return re.search(r"(?:^|/)anthropic(?:/|$)", path) is None
|
|
2221
|
+
|
|
2222
|
+
|
|
2198
2223
|
def bindable_protocols(agent_kind_or_id):
|
|
2199
2224
|
"""该 CLI 可绑定的 wire 协议(与 resolve_binding 的 allowed 一致)。
|
|
2200
2225
|
供死链告警/失败文案解释「为什么绑不上」:claude 只认 anthropic,
|
|
@@ -2851,6 +2876,10 @@ def _wire_base_candidates(base_url, target_proto):
|
|
|
2851
2876
|
cands = [base if base.endswith("/v1") else base + "/v1"]
|
|
2852
2877
|
if not base.endswith("/v1"):
|
|
2853
2878
|
cands.append(base)
|
|
2879
|
+
# `/api/anthropic` 是 Anthropic 专用入口,不要把它拼成
|
|
2880
|
+
# `/api/anthropic[/v1]/responses` 再误记为 Codex 能力。
|
|
2881
|
+
if not _wire_endpoint_compatible(target_proto, base):
|
|
2882
|
+
cands = []
|
|
2854
2883
|
else:
|
|
2855
2884
|
# anthropic 习惯:CLI 在 base 后拼 /v1/messages,base 本身不含 /v1
|
|
2856
2885
|
cands = [base[:-3] if base.endswith("/v1") else base]
|
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, branching, 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, chaptersafe, 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
|
|
@@ -30,17 +30,60 @@ from .repeat_guard import guard as repeat_guard
|
|
|
30
30
|
DEFAULT_RUBRIC = ["情节", "人物", "文笔", "节奏", "吸引力"]
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
class Cancelled(Exception):
|
|
34
|
-
pass
|
|
33
|
+
class Cancelled(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TaskTimeout(Exception):
|
|
38
|
+
pass
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
def _now():
|
|
38
42
|
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
39
43
|
|
|
40
44
|
|
|
41
|
-
def _check_cancel(ev):
|
|
42
|
-
if ev is not None and ev.is_set():
|
|
43
|
-
raise Cancelled()
|
|
45
|
+
def _check_cancel(ev):
|
|
46
|
+
if ev is not None and ev.is_set():
|
|
47
|
+
raise Cancelled()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _run_deadline(run_id):
|
|
51
|
+
"""Convert the persisted wall-clock cutoff into a monotonic deadline."""
|
|
52
|
+
run = store.get_run(run_id) or {}
|
|
53
|
+
try:
|
|
54
|
+
deadline_at = float(run.get("deadline_at"))
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
return time.monotonic() + (deadline_at - time.time())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ensure_budget(run_id):
|
|
61
|
+
deadline = _run_deadline(run_id)
|
|
62
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
63
|
+
store.update_run(run_id, expected_status="running", status="timeout",
|
|
64
|
+
ended_at=_now(), error="任务总时限已到")
|
|
65
|
+
raise TaskTimeout()
|
|
66
|
+
return deadline
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _step_timeout(run_id, requested, deadline=None):
|
|
70
|
+
value = max(0.0, float(requested or runner.DEFAULT_TIMEOUT))
|
|
71
|
+
deadline = deadline if deadline is not None else _run_deadline(run_id)
|
|
72
|
+
if deadline is not None:
|
|
73
|
+
value = min(value, max(0.0, deadline - time.monotonic()))
|
|
74
|
+
return value, deadline
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _planner_call(fn, *args, deadline=None, **kwargs):
|
|
78
|
+
"""Allow older injected planner doubles to omit the new deadline kwarg."""
|
|
79
|
+
if deadline is None:
|
|
80
|
+
return fn(*args, **kwargs)
|
|
81
|
+
try:
|
|
82
|
+
return fn(*args, deadline=deadline, **kwargs)
|
|
83
|
+
except TypeError as exc:
|
|
84
|
+
if "unexpected keyword argument 'deadline'" not in str(exc):
|
|
85
|
+
raise
|
|
86
|
+
return fn(*args, **kwargs)
|
|
44
87
|
|
|
45
88
|
|
|
46
89
|
def _inside(dirpath, target):
|
|
@@ -195,12 +238,12 @@ def _get_session(run_id):
|
|
|
195
238
|
return s
|
|
196
239
|
|
|
197
240
|
|
|
198
|
-
def _make_llm_caller(agent, workdir):
|
|
241
|
+
def _make_llm_caller(agent, workdir, deadline=None):
|
|
199
242
|
"""压缩摘要用 LLM:直接复用当前 step 的 agent(同 CLI 同模型)。"""
|
|
200
243
|
def caller(messages):
|
|
201
244
|
prompt = "\n\n".join(m.get("content", "") for m in messages)
|
|
202
|
-
res = runner.run_agent(agent, prompt, workdir=workdir, readonly=True,
|
|
203
|
-
timeout=300)
|
|
245
|
+
res = runner.run_agent(agent, prompt, workdir=workdir, readonly=True,
|
|
246
|
+
timeout=300, deadline=deadline)
|
|
204
247
|
return res.get("text") or ""
|
|
205
248
|
return caller
|
|
206
249
|
|
|
@@ -303,9 +346,11 @@ def _binding_dead_msg(agent):
|
|
|
303
346
|
"请在「模型调度(可选)」页为该 CLI 指定已启用的供应商")
|
|
304
347
|
|
|
305
348
|
|
|
306
|
-
def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None, require_tools=False):
|
|
307
|
-
"""执行一个智能体步骤并记录。返回 runner 统一结果。"""
|
|
308
|
-
_wait_gate(run_id, ev)
|
|
349
|
+
def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None, require_tools=False):
|
|
350
|
+
"""执行一个智能体步骤并记录。返回 runner 统一结果。"""
|
|
351
|
+
_wait_gate(run_id, ev)
|
|
352
|
+
deadline = _ensure_budget(run_id)
|
|
353
|
+
timeout, deadline = _step_timeout(run_id, timeout, deadline)
|
|
309
354
|
# 绑定解析为空分两种(2026-09-18 起 区分对待):
|
|
310
355
|
# · 从没配过链(binding_configured=False):回落 CLI 本机默认照跑——
|
|
311
356
|
# 用户根本没在 CodeBee 里配供应商,谈不到「烧自己配的配额」;判失败
|
|
@@ -331,7 +376,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
|
|
|
331
376
|
if dead_binding:
|
|
332
377
|
note = ((note + ";") if note else "") + "⚠ " + dead_msg
|
|
333
378
|
step, log_abs = store.add_step(run_id, role, agent["id"],
|
|
334
|
-
agent.get("label", agent["id"]), note=note
|
|
379
|
+
agent.get("label", agent["id"]), note=note,
|
|
380
|
+
model=agent.get("model") or "")
|
|
335
381
|
start = time.time()
|
|
336
382
|
if dead_binding:
|
|
337
383
|
from .error_codes import ErrorCode
|
|
@@ -372,19 +418,24 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
|
|
|
372
418
|
if directive_block:
|
|
373
419
|
prompt = directive_block + "\n\n---\n\n" + prompt
|
|
374
420
|
effective_prompt = (guard["reminder"] + "\n\n---\n\n" + prompt) if guard["reminder"] else prompt
|
|
375
|
-
res = _spawn_step(session_run_id=run_id, role=role, agent=agent,
|
|
376
|
-
prompt=effective_prompt, workdir=workdir, readonly=readonly,
|
|
377
|
-
ev=ev, timeout=timeout, resume=resume, step=step,
|
|
378
|
-
log_abs=log_abs, images=images, require_tools=require_tools
|
|
421
|
+
res = _spawn_step(session_run_id=run_id, role=role, agent=agent,
|
|
422
|
+
prompt=effective_prompt, workdir=workdir, readonly=readonly,
|
|
423
|
+
ev=ev, timeout=timeout, resume=resume, step=step,
|
|
424
|
+
log_abs=log_abs, images=images, require_tools=require_tools,
|
|
425
|
+
deadline=deadline)
|
|
379
426
|
# 先收尾再查取消:取消时进程已被 run_process 杀停,若先抛 Cancelled,
|
|
380
|
-
# 步骤记录会永远停在「运行中」变僵尸(与 _run_verify 的顺序对齐)
|
|
381
|
-
_finish_step_result(run_id, step, res, role, agent, start)
|
|
382
|
-
|
|
427
|
+
# 步骤记录会永远停在「运行中」变僵尸(与 _run_verify 的顺序对齐)
|
|
428
|
+
_finish_step_result(run_id, step, res, role, agent, start)
|
|
429
|
+
if (res.get("raw") or {}).get("deadline_exceeded"):
|
|
430
|
+
store.update_run(run_id, expected_status="running", status="timeout",
|
|
431
|
+
ended_at=_now(), error="任务总时限已到")
|
|
432
|
+
raise TaskTimeout()
|
|
433
|
+
_check_cancel(ev)
|
|
383
434
|
return res
|
|
384
435
|
|
|
385
436
|
|
|
386
|
-
def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None,
|
|
387
|
-
followups=False):
|
|
437
|
+
def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None,
|
|
438
|
+
followups=False):
|
|
388
439
|
"""内置智能体步骤:直连模型 API + 工具循环(builtin_agent),不经 CLI 进程。
|
|
389
440
|
|
|
390
441
|
与 _run_step 对齐的三件事:暂停/取消闸门、运行中指令 drain 注入、重复调用
|
|
@@ -392,8 +443,10 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
|
|
|
392
443
|
日志只有「迭代/工具」摘要行——对话视图吃 output,日志抽屉看工具轨迹。
|
|
393
444
|
followups=True 时从回答末尾解析「建议追问」块(直连对话专用协议):
|
|
394
445
|
剥离出结构化列表落步骤记录,正文保持干净。"""
|
|
395
|
-
_wait_gate(run_id, ev)
|
|
396
|
-
|
|
446
|
+
_wait_gate(run_id, ev)
|
|
447
|
+
deadline = _ensure_budget(run_id)
|
|
448
|
+
step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note,
|
|
449
|
+
model=bi.get("model") or "")
|
|
397
450
|
start = time.time()
|
|
398
451
|
agent_pseudo = {"id": "builtin", "label": "CodeBee", "kind": "builtin", "mode": "real",
|
|
399
452
|
"provider": {"id": bi.get("provider_id") or "",
|
|
@@ -435,9 +488,11 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
|
|
|
435
488
|
def _on_activity(line):
|
|
436
489
|
store.stream_step(run_id, step["n"], activity=str(line or "")[:200])
|
|
437
490
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
491
|
+
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
492
|
+
res = builtin_agent.run(bi, prompt, workdir, timeout=remaining or 180,
|
|
493
|
+
deadline=deadline, cancel_event=ev, log=_log, images=images,
|
|
494
|
+
on_reason=_on_reason, on_stream=_on_stream,
|
|
495
|
+
on_activity=_on_activity)
|
|
441
496
|
if followups and res.get("ok"):
|
|
442
497
|
clean, fups = _parse_followups(res.get("text") or "")
|
|
443
498
|
if fups:
|
|
@@ -458,9 +513,13 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
|
|
|
458
513
|
res["raw"]["duration"] = time.time() - start
|
|
459
514
|
res["tokens"] = int(usage.get("total") or 0)
|
|
460
515
|
res.setdefault("cost_usd", 0.0)
|
|
461
|
-
# 先收尾再查取消:与 _run_step 同序,防步骤记录停在「运行中」变僵尸
|
|
462
|
-
_finish_step_result(run_id, step, res, role, agent_pseudo, start)
|
|
463
|
-
|
|
516
|
+
# 先收尾再查取消:与 _run_step 同序,防步骤记录停在「运行中」变僵尸
|
|
517
|
+
_finish_step_result(run_id, step, res, role, agent_pseudo, start)
|
|
518
|
+
if (res.get("raw") or {}).get("deadline_exceeded"):
|
|
519
|
+
store.update_run(run_id, expected_status="running", status="timeout",
|
|
520
|
+
ended_at=_now(), error="任务总时限已到")
|
|
521
|
+
raise TaskTimeout()
|
|
522
|
+
_check_cancel(ev)
|
|
464
523
|
return res
|
|
465
524
|
|
|
466
525
|
|
|
@@ -483,8 +542,9 @@ def _budget_max_tokens():
|
|
|
483
542
|
return 0
|
|
484
543
|
|
|
485
544
|
|
|
486
|
-
def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
487
|
-
timeout, resume, step, log_abs, images=None, require_tools=False
|
|
545
|
+
def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
546
|
+
timeout, resume, step, log_abs, images=None, require_tools=False,
|
|
547
|
+
deadline=None):
|
|
488
548
|
"""真实 CLI 调用:压缩灰度路径或原路径。"""
|
|
489
549
|
# T2.1 预算闸:已用 token 达到单次 run 上限 → 阻断后续真实调用(ENV_BLOCK)。
|
|
490
550
|
# 只拦「下一步」,允许越过线的当前步完成;auto 续跑可在用户调高预算后接手。
|
|
@@ -507,10 +567,10 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
|
507
567
|
if _compaction_enabled() and not resume:
|
|
508
568
|
# Phase 2(1D):撑爆 → 压缩 → 守门重试;同时把 usage 累进 token_meter(1C)
|
|
509
569
|
session = _get_session(session_run_id)
|
|
510
|
-
llm_caller = _make_llm_caller(agent, workdir)
|
|
511
|
-
call_kwargs = dict(workdir=workdir, readonly=readonly,
|
|
512
|
-
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
513
|
-
images=images, require_tools=require_tools)
|
|
570
|
+
llm_caller = _make_llm_caller(agent, workdir, deadline=deadline)
|
|
571
|
+
call_kwargs = dict(workdir=workdir, readonly=readonly,
|
|
572
|
+
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
573
|
+
images=images, require_tools=require_tools, deadline=deadline)
|
|
514
574
|
|
|
515
575
|
def _call(p, **kw):
|
|
516
576
|
nonlocal usage_recorded
|
|
@@ -535,9 +595,10 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
|
535
595
|
session, _call, prompt, model=agent.get("model") or "",
|
|
536
596
|
llm_caller=llm_caller)
|
|
537
597
|
else:
|
|
538
|
-
res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
|
|
539
|
-
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
540
|
-
resume=resume, images=images, require_tools=require_tools
|
|
598
|
+
res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
|
|
599
|
+
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
600
|
+
resume=resume, images=images, require_tools=require_tools,
|
|
601
|
+
deadline=deadline)
|
|
541
602
|
# 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
|
|
542
603
|
# 下一步永远看到 used=0,max_tokens_per_run 只是一个无效设置。
|
|
543
604
|
if not usage_recorded:
|
|
@@ -724,14 +785,17 @@ def _files_hint(sub):
|
|
|
724
785
|
"`%s`" % str(f) for f in files[:10])
|
|
725
786
|
|
|
726
787
|
|
|
727
|
-
def _run_verify(run_id, task, workdir, ev):
|
|
728
|
-
"""确定性验证。返回 (verify_pass, ran)。"""
|
|
729
|
-
|
|
730
|
-
|
|
788
|
+
def _run_verify(run_id, task, workdir, ev):
|
|
789
|
+
"""确定性验证。返回 (verify_pass, ran)。"""
|
|
790
|
+
deadline = _ensure_budget(run_id)
|
|
791
|
+
if not task.get("verify_command"):
|
|
792
|
+
return True, False
|
|
731
793
|
step, log_abs = store.add_step(run_id, "verify", "builtin", "内置验证器")
|
|
732
794
|
start = time.time()
|
|
733
|
-
|
|
734
|
-
|
|
795
|
+
timeout, deadline = _step_timeout(run_id, 600, deadline)
|
|
796
|
+
r = runner.run_process(shell_cmd=task["verify_command"], cwd=workdir,
|
|
797
|
+
timeout=timeout, deadline=deadline, cancel_event=ev,
|
|
798
|
+
log_path=str(log_abs))
|
|
735
799
|
ok = r["ok"]
|
|
736
800
|
# 与 _finish_step_result 同一套显示层细分:超时/取消杀停不再冒充「失败」
|
|
737
801
|
if r.get("cancelled"):
|
|
@@ -741,9 +805,13 @@ def _run_verify(run_id, task, workdir, ev):
|
|
|
741
805
|
else:
|
|
742
806
|
v_status = "done" if ok else "failed"
|
|
743
807
|
v_sum = "验证通过" if ok else "验证失败(exit %s)" % r["exit_code"]
|
|
744
|
-
store.finish_step(run_id, step["n"], v_status,
|
|
745
|
-
summary=v_sum,
|
|
746
|
-
exit_code=r["exit_code"], duration_s=time.time() - start)
|
|
808
|
+
store.finish_step(run_id, step["n"], v_status,
|
|
809
|
+
summary=v_sum,
|
|
810
|
+
exit_code=r["exit_code"], duration_s=time.time() - start)
|
|
811
|
+
if r.get("deadline_exceeded"):
|
|
812
|
+
store.update_run(run_id, expected_status="running", status="timeout",
|
|
813
|
+
ended_at=_now(), error="任务总时限已到")
|
|
814
|
+
raise TaskTimeout()
|
|
747
815
|
_check_cancel(ev)
|
|
748
816
|
return ok, True
|
|
749
817
|
|
|
@@ -940,6 +1008,72 @@ def _record_dispatch_completed(run_id, task, result, verify_pass=None, review_pa
|
|
|
940
1008
|
pass
|
|
941
1009
|
|
|
942
1010
|
|
|
1011
|
+
def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
|
|
1012
|
+
stats, prompt, workdir, step_wd, ev, resume_ctx,
|
|
1013
|
+
draft_note, draft_res):
|
|
1014
|
+
"""Auto content tasks may try another configured CLI after a draft failure.
|
|
1015
|
+
|
|
1016
|
+
Model/provider failover has already been exhausted inside run_agent. Keep
|
|
1017
|
+
manual execution, user cancellation and resumed sessions pinned to the
|
|
1018
|
+
user's explicit choice.
|
|
1019
|
+
"""
|
|
1020
|
+
raw = draft_res.get("raw") or {}
|
|
1021
|
+
if (mode == "manual" or impl.get("mode") != "real" or resume_ctx
|
|
1022
|
+
or raw.get("cancelled") or draft_res.get("error_code") == "CANCELLED"):
|
|
1023
|
+
return draft_res, impl, draft_note
|
|
1024
|
+
|
|
1025
|
+
tried = {impl.get("id")}
|
|
1026
|
+
failures = ["%s:%s" % (impl.get("id") or "CLI",
|
|
1027
|
+
(draft_res.get("error") or "起草失败")[:180])]
|
|
1028
|
+
dead_upstreams = []
|
|
1029
|
+
first_upstreams = router.agent_upstreams(impl.get("id"))
|
|
1030
|
+
if first_upstreams:
|
|
1031
|
+
dead_upstreams.append(first_upstreams)
|
|
1032
|
+
current = impl
|
|
1033
|
+
last_impl = impl
|
|
1034
|
+
last_result = draft_res
|
|
1035
|
+
last_note = draft_note
|
|
1036
|
+
|
|
1037
|
+
while True:
|
|
1038
|
+
_check_cancel(ev)
|
|
1039
|
+
candidate, reason = router.pick_switch_candidate(
|
|
1040
|
+
agents, "implement", task.get("type") or "novel", stats,
|
|
1041
|
+
exclude=tried, dead_upstreams=dead_upstreams)
|
|
1042
|
+
if candidate is None or candidate.get("id") in tried:
|
|
1043
|
+
break
|
|
1044
|
+
tried.add(candidate.get("id"))
|
|
1045
|
+
if candidate.get("mode") != "real":
|
|
1046
|
+
continue
|
|
1047
|
+
|
|
1048
|
+
why = (last_result.get("error") or "起草失败")[:180]
|
|
1049
|
+
switch_note = ("起草自动换 CLI:%s → %s(%s);前序失败:%s" % (
|
|
1050
|
+
current.get("id") or "CLI", candidate.get("id") or "CLI",
|
|
1051
|
+
reason or "自动路由", why))
|
|
1052
|
+
attempt_note = (draft_note + ";" if draft_note else "") + switch_note
|
|
1053
|
+
bound = modelhub.bind_agent(candidate, difficulty)
|
|
1054
|
+
last_result = _run_step(
|
|
1055
|
+
run_id, "draft", bound, prompt, step_wd, readonly=False, ev=ev,
|
|
1056
|
+
note=attempt_note, images=_task_images(task, workdir))
|
|
1057
|
+
last_impl = candidate
|
|
1058
|
+
last_note = attempt_note
|
|
1059
|
+
if last_result.get("ok"):
|
|
1060
|
+
return last_result, candidate, attempt_note
|
|
1061
|
+
|
|
1062
|
+
failures.append("%s:%s" % (candidate.get("id") or "CLI",
|
|
1063
|
+
(last_result.get("error") or "起草失败")[:180]))
|
|
1064
|
+
if (last_result.get("raw") or {}).get("cancelled"):
|
|
1065
|
+
break
|
|
1066
|
+
ups = router.agent_upstreams(candidate.get("id"))
|
|
1067
|
+
if ups:
|
|
1068
|
+
dead_upstreams.append(ups)
|
|
1069
|
+
current = candidate
|
|
1070
|
+
|
|
1071
|
+
if last_impl is not impl:
|
|
1072
|
+
last_result = dict(last_result)
|
|
1073
|
+
last_result["error"] = "CLI 自动切换均失败:%s" % ";".join(failures[-4:])
|
|
1074
|
+
return last_result, last_impl, last_note
|
|
1075
|
+
|
|
1076
|
+
|
|
943
1077
|
def _run_code(run, task, agents, ev, stats, mode):
|
|
944
1078
|
run_id = run["id"]
|
|
945
1079
|
workdir = task["workdir"]
|
|
@@ -989,11 +1123,14 @@ def _run_code(run, task, agents, ev, stats, mode):
|
|
|
989
1123
|
_wait_gate(run_id, ev)
|
|
990
1124
|
plan_step, plan_log = store.add_step(run_id, "plan", impl["id"], impl.get("label"),
|
|
991
1125
|
note=route.get("implementer", ""))
|
|
992
|
-
plan =
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1126
|
+
plan = _planner_call(
|
|
1127
|
+
planner.make_code_plan, _steered_task(run_id, task),
|
|
1128
|
+
modelhub.bind_agent(impl, difficulty),
|
|
1129
|
+
_resume_workdir(resume_ctx, workdir), ev,
|
|
1130
|
+
resume=resume_ctx["session"] if resume_ctx else None,
|
|
1131
|
+
log_path=str(plan_log) if plan_log else None,
|
|
1132
|
+
deadline=_run_deadline(run_id))
|
|
1133
|
+
_ensure_budget(run_id)
|
|
997
1134
|
# 规划器判定优先于启发式(仅当用户未显式指定难度)
|
|
998
1135
|
if not explicit and plan.get("difficulty") in ("easy", "hard"):
|
|
999
1136
|
difficulty = plan["difficulty"]
|
|
@@ -2108,8 +2245,9 @@ def _wc(text):
|
|
|
2108
2245
|
|
|
2109
2246
|
|
|
2110
2247
|
def _write_chapter(workdir, i, text):
|
|
2111
|
-
|
|
2112
|
-
|
|
2248
|
+
# 安全落盘委托(inkos 安全章节工作区借鉴):tmp+原子改名,写一半
|
|
2249
|
+
# 崩溃不留半章冒充成稿;路径守卫在 chaptersafe(resolve+parents)
|
|
2250
|
+
chaptersafe.atomic_write_chapter(workdir, i, text)
|
|
2113
2251
|
|
|
2114
2252
|
|
|
2115
2253
|
def _all_ge(scores, threshold):
|
|
@@ -2260,8 +2398,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2260
2398
|
_wait_gate(run_id, ev)
|
|
2261
2399
|
outline_step, outline_log = store.add_step(run_id, "outline", impl["id"], impl.get("label"),
|
|
2262
2400
|
note=route.get("author", ""))
|
|
2263
|
-
outline =
|
|
2264
|
-
|
|
2401
|
+
outline = _planner_call(
|
|
2402
|
+
planner.make_serial_outline, _steered_task(run_id, task), impl, workdir, ev,
|
|
2403
|
+
log_path=str(outline_log) if outline_log else None,
|
|
2404
|
+
deadline=_run_deadline(run_id))
|
|
2405
|
+
_ensure_budget(run_id)
|
|
2265
2406
|
if outline.get("degraded") and impl.get("mode") != "mock":
|
|
2266
2407
|
# 兜底模板只有章号没有情节,据此写出的两万字等于废稿——
|
|
2267
2408
|
# 中止并交给自动续跑等编排者恢复后重试,而不是空转烧配额。
|
|
@@ -3431,8 +3572,11 @@ def _run_content_review(run, task, agents, ev, stats, mode):
|
|
|
3431
3572
|
draft_note = route.get("author", "") if mode != "manual" else ""
|
|
3432
3573
|
|
|
3433
3574
|
# 编排者大纲:只对真实执行有意义;失败静默退回无大纲(喂入带指令的任务副本)
|
|
3434
|
-
outline = (planner.make_review_outline
|
|
3435
|
-
|
|
3575
|
+
outline = (_planner_call(planner.make_review_outline,
|
|
3576
|
+
_steered_task(run_id, task),
|
|
3577
|
+
deadline=_run_deadline(run_id))
|
|
3578
|
+
if workflow["outline"] and impl.get("mode") != "mock" else None)
|
|
3579
|
+
_ensure_budget(run_id)
|
|
3436
3580
|
if outline:
|
|
3437
3581
|
workflow = task_compile.content_workflow(
|
|
3438
3582
|
task, difficulty, plan=outline, mode=mode)
|
|
@@ -3491,6 +3635,27 @@ def _run_content_review(run, task, agents, ev, stats, mode):
|
|
|
3491
3635
|
ev=ev, note=draft_note,
|
|
3492
3636
|
resume=resume_ctx["session"] if resume_ctx else None,
|
|
3493
3637
|
images=_task_images(task, workdir))
|
|
3638
|
+
if not draft_res["ok"]:
|
|
3639
|
+
draft_res, actual_impl, fallback_note = _retry_content_draft_with_cli(
|
|
3640
|
+
run_id, task, agents, impl, difficulty, mode, stats,
|
|
3641
|
+
_draft_prompt_for(ms_name), workdir, step_wd, ev, resume_ctx,
|
|
3642
|
+
draft_note, draft_res)
|
|
3643
|
+
if actual_impl.get("id") != impl.get("id"):
|
|
3644
|
+
impl = actual_impl
|
|
3645
|
+
draft_note = fallback_note
|
|
3646
|
+
route["author"] = fallback_note
|
|
3647
|
+
if mode != "manual":
|
|
3648
|
+
critic_pool, route["critics"] = router.pick_critics(
|
|
3649
|
+
agents, task.get("type") or "novel", stats, impl=impl)
|
|
3650
|
+
critics = critic_pool[:workflow["reviewers"]]
|
|
3651
|
+
plan = planner.make_novel_plan(_steered_task(run_id, task), impl, critics)
|
|
3652
|
+
store.update_run(run_id, route=route, plan=plan)
|
|
3653
|
+
_record_actual_route(
|
|
3654
|
+
run_id, task, agents, stats, impl, critics=critics,
|
|
3655
|
+
implement_reason=route.get("author", ""),
|
|
3656
|
+
review_reason=route.get("critics", ""))
|
|
3657
|
+
if draft_res.get("raw", {}).get("cancelled"):
|
|
3658
|
+
_check_cancel(ev)
|
|
3494
3659
|
if not draft_res["ok"]:
|
|
3495
3660
|
store.update_run(run_id, expected_status="running", status="failed",
|
|
3496
3661
|
error="起草失败: %s" % draft_res.get("error"),
|
|
@@ -3899,10 +4064,14 @@ def execute_run(run_id):
|
|
|
3899
4064
|
if store.update_run(run_id, expected_status=initial_status, status="running",
|
|
3900
4065
|
started_at=_now()) is None:
|
|
3901
4066
|
return
|
|
3902
|
-
if task is None:
|
|
3903
|
-
store.update_run(run_id, expected_status="running", status="failed",
|
|
3904
|
-
error="找不到任务 %s" % run.get("task_id"), ended_at=_now())
|
|
3905
|
-
return
|
|
4067
|
+
if task is None:
|
|
4068
|
+
store.update_run(run_id, expected_status="running", status="failed",
|
|
4069
|
+
error="找不到任务 %s" % run.get("task_id"), ended_at=_now())
|
|
4070
|
+
return
|
|
4071
|
+
try:
|
|
4072
|
+
_ensure_budget(run_id)
|
|
4073
|
+
except TaskTimeout:
|
|
4074
|
+
return
|
|
3906
4075
|
# 统一任务编译:旧字段继续供各引擎读取,规格作为运行级诊断与调度输入落盘。
|
|
3907
4076
|
task_spec = task_compile.compile_task(task)
|
|
3908
4077
|
store.update_run(run_id, task_spec=task_spec,
|
|
@@ -4021,9 +4190,12 @@ def execute_run(run_id):
|
|
|
4021
4190
|
critics, impl, route, resume_ctx, difficulty)
|
|
4022
4191
|
else:
|
|
4023
4192
|
_run_content_review(run, task, agents, ev, stats, mode)
|
|
4024
|
-
except
|
|
4025
|
-
store.update_run(run_id, expected_status="running",
|
|
4026
|
-
status="
|
|
4193
|
+
except TaskTimeout:
|
|
4194
|
+
store.update_run(run_id, expected_status="running",
|
|
4195
|
+
status="timeout", ended_at=_now(), error="任务总时限已到")
|
|
4196
|
+
except Cancelled:
|
|
4197
|
+
store.update_run(run_id, expected_status="running",
|
|
4198
|
+
status="cancelled", ended_at=_now())
|
|
4027
4199
|
except Exception as e:
|
|
4028
4200
|
import traceback
|
|
4029
4201
|
store.update_run(run_id, expected_status="running", status="failed",
|
package/app/core/planner.py
CHANGED
|
@@ -69,7 +69,7 @@ def _log_streamer(log_path, min_chars=400, min_secs=0.8):
|
|
|
69
69
|
return cb
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
def _outline_timeout():
|
|
72
|
+
def _outline_timeout():
|
|
73
73
|
"""大纲/规划的 CLI 兜底超时(秒):env TUTTI_OUTLINE_TIMEOUT(秒)优先,
|
|
74
74
|
其次设置 orchestrator.outline_timeout_s,缺省 900。"""
|
|
75
75
|
try:
|
|
@@ -86,7 +86,16 @@ def _outline_timeout():
|
|
|
86
86
|
return v
|
|
87
87
|
except Exception:
|
|
88
88
|
pass
|
|
89
|
-
return DEFAULT_OUTLINE_TIMEOUT
|
|
89
|
+
return DEFAULT_OUTLINE_TIMEOUT
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _deadline_timeout(deadline, requested):
|
|
93
|
+
if deadline is None:
|
|
94
|
+
return requested
|
|
95
|
+
try:
|
|
96
|
+
return max(1, min(float(requested), deadline - time.monotonic()))
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
return requested
|
|
90
99
|
|
|
91
100
|
|
|
92
101
|
def _log_usage(source, role, task, res, agent=None, tool="", model="", provider="",
|
|
@@ -346,7 +355,8 @@ def book_volume_plan(task, outline, upto):
|
|
|
346
355
|
return volumes.merge_titles(plan, named)
|
|
347
356
|
|
|
348
357
|
|
|
349
|
-
def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None
|
|
358
|
+
def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None,
|
|
359
|
+
deadline=None):
|
|
350
360
|
"""连载大纲:编排者 API 优先 → 作者 CLI → 模板。返回 {book_title, chapters:[{title,beats,hook}]}。
|
|
351
361
|
|
|
352
362
|
续写批次(serial.start_chapter > 1):改用续写大纲提示词,注入前情大纲与
|
|
@@ -443,8 +453,8 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
443
453
|
# prompt 逐字节相同,命中省一次全量规划调用。第 2 次尝试故意
|
|
444
454
|
# 旁路缓存——首试 ok 但 JSON 不合法时,重试要的是「再抽一次样
|
|
445
455
|
# 本」而不是同一份坏文本。
|
|
446
|
-
res = modelhub.chat(prov["id"], model, prompt,
|
|
447
|
-
max_tokens=16000, timeout=300, on_delta=cb,
|
|
456
|
+
res = modelhub.chat(prov["id"], model, prompt,
|
|
457
|
+
max_tokens=16000, timeout=_deadline_timeout(deadline, 300), on_delta=cb,
|
|
448
458
|
reasoning_effort=_reasoning_effort(task),
|
|
449
459
|
cache_ttl=3600 if _attempt == 1 else 0)
|
|
450
460
|
cb.flush()
|
|
@@ -473,7 +483,8 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
473
483
|
_append_log(log_path, "===== 作者 CLI(%s)=====" % author_agent.get("id", "?"))
|
|
474
484
|
res = runner.run_agent(modelhub.bind_agent(author_agent), prompt,
|
|
475
485
|
workdir=workdir or task.get("workdir"), readonly=True,
|
|
476
|
-
timeout=_outline_timeout(),
|
|
486
|
+
timeout=_deadline_timeout(deadline, _outline_timeout()),
|
|
487
|
+
deadline=deadline, cancel_event=ev,
|
|
477
488
|
log_path=log_path)
|
|
478
489
|
_log_usage("outline", "outline", task, res, agent=author_agent)
|
|
479
490
|
if not res["ok"]:
|
|
@@ -591,11 +602,13 @@ def _reasoning_effort(task):
|
|
|
591
602
|
return "medium"
|
|
592
603
|
|
|
593
604
|
|
|
594
|
-
def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=None
|
|
605
|
+
def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=None,
|
|
606
|
+
deadline=None):
|
|
595
607
|
"""代码任务计划:编排者 API 优先 → CLI 智能体 → 单步模板。"""
|
|
596
608
|
orch = _orchestrator()
|
|
597
609
|
if orch:
|
|
598
|
-
plan = _orch_code_plan(task, orch[0], orch[1], log_path=log_path
|
|
610
|
+
plan = _orch_code_plan(task, orch[0], orch[1], log_path=log_path,
|
|
611
|
+
deadline=deadline)
|
|
599
612
|
if plan:
|
|
600
613
|
return plan
|
|
601
614
|
if planner_agent is None:
|
|
@@ -607,7 +620,8 @@ def make_code_plan(task, planner_agent, workdir, ev=None, resume=None, log_path=
|
|
|
607
620
|
.replace("__CONTEXT__", task.get("context") or "(无)")
|
|
608
621
|
.replace("__VERIFY__", task.get("verify_command") or "(未配置)"))
|
|
609
622
|
res = runner.run_agent(planner_agent, prompt, workdir=workdir, readonly=True,
|
|
610
|
-
timeout=
|
|
623
|
+
timeout=_deadline_timeout(deadline, 300), deadline=deadline,
|
|
624
|
+
cancel_event=ev, resume=resume,
|
|
611
625
|
log_path=log_path)
|
|
612
626
|
_log_usage("plan", "plan", task, res, agent=planner_agent)
|
|
613
627
|
data = runner.extract_json(res.get("text") or "")
|
|
@@ -624,7 +638,7 @@ def _fallback_code_plan(task, note):
|
|
|
624
638
|
"steps": [{"title": "实现任务", "detail": task["goal"]}]}
|
|
625
639
|
|
|
626
640
|
|
|
627
|
-
def _orch_code_plan(task, prov, model, log_path=None):
|
|
641
|
+
def _orch_code_plan(task, prov, model, log_path=None, deadline=None):
|
|
628
642
|
cb = _log_streamer(log_path)
|
|
629
643
|
# §07 T2.2:代码计划同任务同 prompt(断点续跑/重试),TTL 1h 精确缓存
|
|
630
644
|
res = modelhub.chat(prov["id"], model,
|
|
@@ -632,7 +646,7 @@ def _orch_code_plan(task, prov, model, log_path=None):
|
|
|
632
646
|
.replace("__GOAL__", task["goal"])
|
|
633
647
|
.replace("__CONTEXT__", task.get("context") or "(无)")
|
|
634
648
|
.replace("__VERIFY__", task.get("verify_command") or "(未配置)")),
|
|
635
|
-
max_tokens=8000, timeout=300, on_delta=cb,
|
|
649
|
+
max_tokens=8000, timeout=_deadline_timeout(deadline, 300), on_delta=cb,
|
|
636
650
|
reasoning_effort=_reasoning_effort(task),
|
|
637
651
|
cache_ttl=3600)
|
|
638
652
|
cb.flush()
|
|
@@ -652,7 +666,7 @@ def _orch_code_plan(task, prov, model, log_path=None):
|
|
|
652
666
|
"workflow": _plan_workflow(data)}
|
|
653
667
|
|
|
654
668
|
|
|
655
|
-
def make_review_outline(task):
|
|
669
|
+
def make_review_outline(task, deadline=None):
|
|
656
670
|
"""review 类任务:编排者产出写作大纲(失败返回 None,起草退回无大纲)。"""
|
|
657
671
|
orch = _orchestrator()
|
|
658
672
|
if not orch:
|
|
@@ -662,7 +676,7 @@ def make_review_outline(task):
|
|
|
662
676
|
(REVIEW_OUTLINE_PROMPT
|
|
663
677
|
.replace("__GOAL__", task["goal"])
|
|
664
678
|
.replace("__CONTEXT__", task.get("context") or "(无)")),
|
|
665
|
-
max_tokens=8000, timeout=300,
|
|
679
|
+
max_tokens=8000, timeout=_deadline_timeout(deadline, 300),
|
|
666
680
|
reasoning_effort=_reasoning_effort(task),
|
|
667
681
|
cache_ttl=3600) # §07 T2.2:同任务重试幂等,TTL 1h
|
|
668
682
|
_log_usage("outline", "outline", task, res, model=model,
|