codebee 0.1.49 → 0.1.51

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.
@@ -419,7 +419,25 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
419
419
  def _log(line):
420
420
  lines.append(str(line))
421
421
 
422
- res = builtin_agent.run(bi, prompt, workdir, cancel_event=ev, log=_log, images=images)
422
+ # 思考过程可视化(2026-09-22 用户诉求):模型的思维链/正文/工具活动边收边
423
+ # 落进步骤记录(store.stream_step 内部节流),对话时间线 2s 轮询即能读到
424
+ # 「它正在想什么」——此前整轮只有三点打字动画。
425
+ live = {"thinking": "", "text": ""}
426
+
427
+ def _on_reason(chunk):
428
+ live["thinking"] += chunk or ""
429
+ store.stream_step(run_id, step["n"], thinking=live["thinking"])
430
+
431
+ def _on_stream(acc):
432
+ live["text"] = acc or ""
433
+ store.stream_step(run_id, step["n"], text=live["text"])
434
+
435
+ def _on_activity(line):
436
+ store.stream_step(run_id, step["n"], activity=str(line or "")[:200])
437
+
438
+ res = builtin_agent.run(bi, prompt, workdir, cancel_event=ev, log=_log, images=images,
439
+ on_reason=_on_reason, on_stream=_on_stream,
440
+ on_activity=_on_activity)
423
441
  if followups and res.get("ok"):
424
442
  clean, fups = _parse_followups(res.get("text") or "")
425
443
  if fups:
@@ -427,6 +445,10 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
427
445
  res["followups"] = fups
428
446
  if log_abs:
429
447
  try:
448
+ # 思考过程同时进步骤日志(日志抽屉与对话气泡同源,便于事后复盘)
449
+ think = (res.get("reasoning") or "").strip()
450
+ if think:
451
+ lines.append("[思考过程]\n" + think)
430
452
  log_abs.write_text("\n".join(lines) + "\n", encoding="utf-8")
431
453
  except Exception:
432
454
  pass
@@ -550,7 +572,9 @@ def _finish_step_result(run_id, step, res, role, agent, start):
550
572
  # 智能体的最终回答(runner 已从 JSONL 事件流里抽出 agent_message)。
551
573
  # 对话视图直读这个;日志文件是全量事件流,塞进气泡就成了「看日志」。
552
574
  output=(res.get("text") or ""),
553
- followups=res.get("followups"))
575
+ followups=res.get("followups"),
576
+ # 思考过程(内置智能体流式抓取):落进步骤记录,对话气泡折叠展示
577
+ thinking=res.get("reasoning") or None)
554
578
  # 错误台账:失败/超时各记一条结构化记录(遥测与诊断包的数据源)。
555
579
  # 用户主动取消不入账——那不是产品问题;detail 只存脱敏后的失败摘录。
556
580
  if status in ("failed", "timeout"):
@@ -1090,29 +1114,53 @@ def _run_code(run, task, agents, ev, stats, mode):
1090
1114
  and _code_bestof(run, task, impl_agent, difficulty, ev)):
1091
1115
  return True
1092
1116
  # 实现步失败不立刻判死:2026-09-16 实测配额烧干时 5 连跑全在同一条 CLI 上
1093
- # 失败收场,而健康的 opencode 一直在旁观望——跨 CLI 换将重试一次
1094
- # (mode=manual 尊重用户指定,不换)。
1117
+ # 失败收场,而健康的 opencode 一直在旁观望——跨 CLI 换将重试
1118
+ # (mode=manual 尊重用户指定,不换)。2026-09-22 续修:配额/限流类死亡
1119
+ # 是确定性秒死,允许继续走查候选名单(同上游让位异上游);其余死因
1120
+ # (超时等)仍只换一次,防着在坏候选上再烧一整个超时。
1095
1121
  if mode == "auto" and impl_agent.get("mode") == "real":
1096
- ex = {impl_agent["id"], "mock-a", "mock-b"}
1097
- other, other_reason = router.pick(agents, "implement", "code", stats,
1098
- exclude=ex)
1099
- if other is not None and other.get("mode") == "real":
1122
+ tried = {impl_agent["id"], "mock-a", "mock-b"}
1123
+ notes = []
1124
+ dead_ups = [] # 配额死亡候选的上游集合:换将优先异上游
1125
+ prev_id = impl_agent["id"]
1126
+ other = None
1127
+ while True:
1128
+ other, other_reason = router.pick_switch_candidate(
1129
+ agents, "implement", "code", stats,
1130
+ exclude=tried, dead_upstreams=dead_ups)
1131
+ if other is None or other.get("mode") != "real":
1132
+ other = None
1133
+ break
1100
1134
  note = "实现步失败自动换将 %s → %s:%s。失败原因:%s" % (
1101
- impl_agent["id"], other["id"], other_reason,
1135
+ prev_id, other["id"], other_reason,
1102
1136
  (res.get("error") or "")[:200])
1103
1137
  ok2, res = _run_one(other)
1104
1138
  if ok2:
1105
- store.update_run(run_id, error="", route_note=note)
1139
+ route_note = ";".join(notes + [note])
1140
+ store.update_run(run_id, error="", route_note=route_note)
1106
1141
  _record_actual_route(
1107
1142
  run_id, task, agents, stats, other,
1108
1143
  implementers=[other], reviewer=reviewer,
1109
- implement_reason=note,
1144
+ implement_reason=route_note,
1110
1145
  review_reason=route.get("reviewer", ""),
1111
1146
  direct=not workflow["review_required"])
1112
1147
  return True
1113
- res_err = "%s;换将后仍失败:%s" % (note, (res.get("error") or "")[:200])
1148
+ notes.append(note)
1149
+ tried.add(other["id"])
1150
+ prev_id = other["id"]
1151
+ if runner._quota_error(res.get("error") or ""):
1152
+ ups = router.agent_upstreams(other["id"])
1153
+ if ups:
1154
+ dead_ups.append(ups)
1155
+ else:
1156
+ break # 非配额死因:只换一次就收手
1157
+ tail_err = (res.get("error") or "")[:200]
1158
+ if notes:
1159
+ res_err = ";".join(notes) + ";换将后仍失败:%s" % tail_err
1160
+ elif other is None:
1161
+ res_err = "实现步骤失败(无其他真实 CLI 可换将): %s" % tail_err
1114
1162
  else:
1115
- res_err = "实现步骤失败(无其他真实 CLI 可换将): %s" % res.get("error")
1163
+ res_err = "实现步骤失败: %s" % tail_err
1116
1164
  else:
1117
1165
  res_err = "实现步骤失败: %s" % res.get("error")
1118
1166
  store.update_run(run_id, expected_status="running", status="failed",
@@ -2085,20 +2133,6 @@ def _weakest_chapters(chapter_scores, global_means, threshold, limit=2):
2085
2133
  return cand[:limit]
2086
2134
 
2087
2135
 
2088
- def _inject_detection(tpl, text, task):
2089
- """把确定性检测行(AI 味/叙事架构/节奏)插进评审提示词。
2090
-
2091
- 命中才插,位置固定为「## 待评审稿件」之前——与技能包/圣经/知识库注入
2092
- 同一位置,保持前缀稳定(§07 T1.2' 前缀缓存)。单稿件评审与连载逐章
2093
- 评审共用本函数:此前连载逐章完全没有这条参考线(旗舰场景漏挂)。
2094
- """
2095
- det = aiflavor.report_line(text, task.get("type"))
2096
- if not det:
2097
- return tpl
2098
- block = "## 确定性检测结果(供评审参考)\n%s\n\n" % det
2099
- return tpl.replace("## 待评审稿件", block + "## 待评审稿件", 1)
2100
-
2101
-
2102
2136
  def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route, resume_ctx, difficulty):
2103
2137
  """连载流水线:大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书。"""
2104
2138
  import json as _json
@@ -2175,9 +2209,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2175
2209
  if kb:
2176
2210
  tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % kb, 1)
2177
2211
  # 确定性检测(AI 味/叙事架构/节奏)此前只挂在单稿件评审上,连载逐章
2178
- # 评审从未拿到——连载是我们的旗舰场景,逐章节奏与钩子恰恰最需要这条
2179
- # 参考线。与单稿件一致:命中才追加,只作参考不直接扣分。
2180
- tpl = _inject_detection(tpl, text, task)
2212
+ # 从未拿到——逐章节奏与钩子恰恰最需要这条参考线(命中才追加,不扣分)
2213
+ tpl = aiflavor.inject_into_prompt(tpl, text, task.get("type"))
2181
2214
  return tpl.replace("__DIMKEYS__", dimkey).replace(
2182
2215
  "__MANUSCRIPT__", text or "(稿件为空!)")
2183
2216
 
@@ -2338,50 +2371,92 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2338
2371
 
2339
2372
  def run_critique(text, rnd, note_extra="", critic_sids=None, event_check=""):
2340
2373
  """一轮多维评审:返回 (cj_by_agent, scored)。变体赛马与主循环共用。
2341
- event_check:本章大纲核对块(逐项目标审稿),随评审下发并回收标记块。"""
2374
+ event_check:本章大纲核对块(逐项目标审稿),随评审下发并回收标记块。
2375
+
2376
+ 多评审并发跑(2026-09-22):评审占运行总时长的六成以上,而各评审之间
2377
+ 互不依赖(同一份稿件、各自视角),串行等待纯属浪费——每位评审的墙上
2378
+ 时间直接叠加。与「同章多稿赛马」「best-of 候选」同一套线程范式(那两处
2379
+ 早已多线程调用 _run_step)。共享结构的写入全部收敛到 join 之后按
2380
+ critics 原顺序合并,保证与串行版逐字节同结果(ev_check 取第一份非空、
2381
+ issues/ledger 的先后顺序都不变),只在耗时上取并行收益。"""
2342
2382
  cj_map, sids = {}, dict(critic_sids or {})
2343
2383
  scored = 0 # 真正给出分数的评审数;失败/不可解析不得当成 0 分计入
2344
- for agent in critics:
2345
- role = "critique-c%d" % i
2384
+ role = "critique-c%d" % i
2385
+ results = {}
2386
+ # 绑定解析提前到主线程:bind_agent 会把链首同步进 CLI 自家配置(写盘),
2387
+ # 多线程同时调它有写盘竞争。解析结果随线程参数传入,线程内只做调用。
2388
+ bound = [modelhub.bind_agent(a, difficulty) for a in critics]
2389
+
2390
+ def _critique_one(idx, agent):
2391
+ """单评审执行体(线程内只做调用与解析,不改共享结构)。
2392
+
2393
+ 异常不吞:记进 results 由主线程按原顺序重抛——串行版的
2394
+ Cancelled/管道异常语义(上抛打断本 run)必须原样保留。"""
2395
+ try:
2396
+ _critique_one_inner(idx, agent)
2397
+ except BaseException as exc: # noqa: BLE001(含 Cancelled)
2398
+ results[idx] = {"error": exc}
2399
+
2400
+ def _critique_one_inner(idx, agent):
2346
2401
  if agent.get("mode") == "mock":
2347
2402
  step, log_abs = store.add_step(run_id, role, agent["id"], agent.get("label"))
2348
2403
  time.sleep(0.15)
2349
2404
  cj = mocks.critique(agent["id"], rnd, dims, threshold_ch)
2350
- scored += 1
2351
2405
  store.finish_step(run_id, step["n"], "done",
2352
2406
  summary="均分 %.1f:%s" % (
2353
2407
  sum(cj["scores"].values()) / max(1, len(dims)),
2354
2408
  cj["summary"]),
2355
2409
  duration_s=0.15)
2356
- else:
2357
- lens = _critic_lens(critics, agent)
2358
- res = _run_step(run_id, role, modelhub.bind_agent(agent, difficulty),
2359
- crit_prompt_for(
2360
- text,
2361
- note=("小说第 %d 章" % i) + (
2362
- "|你的专属评审视角:%s(其他评审会覆盖其余视角,"
2363
- "请深挖你的镜头,但所有维度仍需打分)" % lens)
2364
- if lens else "",
2365
- event_check=event_check) + note_extra,
2366
- workdir, readonly=True, ev=ev,
2367
- resume=sids.get(agent["id"]))
2368
- cj = _critique_json(res, dims)
2369
- if cj.get("scores"):
2370
- scored += 1
2371
- # 逐项目标审稿 + 资源账本:解析评审回复里的标记块(借鉴
2372
- # AI-Novel-Writer 逐项核对/角色资源账本)。多评审取第一份
2373
- # 非空即可,聚合时已在前两处赋值处去重。
2374
- evl = _parse_tagged_lines(res.get("text"), "event_check")
2375
- if evl and not ev_check_lines[0]:
2376
- ev_check_lines[0] = evl
2377
- ledger_lines.extend(_parse_tagged_lines(res.get("text"), "ledger"))
2410
+ results[idx] = {"agent": agent, "cj": cj, "evl": [],
2411
+ "ledger": [], "sid": None}
2412
+ return
2413
+ lens = _critic_lens(critics, agent)
2414
+ res = _run_step(run_id, role, bound[idx],
2415
+ crit_prompt_for(
2416
+ text,
2417
+ note=("小说第 %d 章" % i) + (
2418
+ "|你的专属评审视角:%s(其他评审会覆盖其余视角,"
2419
+ "请深挖你的镜头,但所有维度仍需打分)" % lens)
2420
+ if lens else "",
2421
+ event_check=event_check) + note_extra,
2422
+ workdir, readonly=True, ev=ev,
2423
+ resume=sids.get(agent["id"]))
2424
+ results[idx] = {
2425
+ "agent": agent, "cj": _critique_json(res, dims),
2426
+ # 逐项目标审稿 + 资源账本:标记块先各存各的,join 后按序合并
2427
+ "evl": _parse_tagged_lines(res.get("text"), "event_check"),
2428
+ "ledger": _parse_tagged_lines(res.get("text"), "ledger"),
2378
2429
  # §07 T1.1:记录该评审的会话 id(第 2 轮复用)
2379
- csid = _resume_sid(agent, res.get("sid"))
2380
- if csid:
2381
- sids[agent["id"]] = csid
2430
+ "sid": _resume_sid(agent, res.get("sid")),
2431
+ }
2432
+
2433
+ threads = []
2434
+ for idx, agent in enumerate(critics):
2435
+ th = threading.Thread(target=_critique_one, args=(idx, agent),
2436
+ name="crit-%s-c%d-%d" % (run_id, i, idx), daemon=True)
2437
+ threads.append(th)
2438
+ th.start()
2439
+ for th in threads:
2440
+ th.join(3000)
2441
+ _check_cancel(ev)
2442
+
2443
+ # join 后按原顺序合并(确定性:与串行版结果一致)
2444
+ for idx in range(len(critics)):
2445
+ r = results.get(idx)
2446
+ if r is None:
2447
+ continue # 该评审线程超时未回,按「没出分」处理
2448
+ if "error" in r:
2449
+ raise r["error"] # 还原串行版的异常上抛语义
2450
+ agent, cj = r["agent"], r["cj"]
2451
+ if cj.get("scores"):
2452
+ scored += 1
2453
+ if r["evl"] and not ev_check_lines[0]:
2454
+ ev_check_lines[0] = r["evl"]
2455
+ ledger_lines.extend(r["ledger"])
2456
+ if r["sid"]:
2457
+ sids[agent["id"]] = r["sid"]
2382
2458
  cj_map[agent["id"]] = cj
2383
2459
  issues_all.extend({"chapter": i, **it} for it in (cj.get("issues") or [])[:6])
2384
- _check_cancel(ev)
2385
2460
 
2386
2461
  # 评审者级 fallback:名单内评审全挂(网关抖动/CLI 故障)时,
2387
2462
  # 从其它已启用真实智能体补位至多 2 个(排除 mock 与已试过的),
@@ -2496,6 +2571,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2496
2571
  chapter_reason = route.get("author", "")
2497
2572
  last_attempt_impl = impl
2498
2573
  last_attempt_reason = chapter_reason
2574
+ prev_prompt = None # 上一次实际下发的提示词
2575
+ prev_timed_out = False # 上一次是否「超时/停滞」收场
2499
2576
  for draft_attempt in range(3):
2500
2577
  if draft_attempt:
2501
2578
  # 30s / 60s 退避;ev.wait 睡等可被取消即刻唤醒
@@ -2511,11 +2588,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2511
2588
  # 30KB 对讯飞必挂)
2512
2589
  sk2, bible2, _note = _shrink_context_block(sk_block, bible, budget=12000)
2513
2590
  use_prompt = prompt.replace(sk_block, sk2).replace(bible, bible2)
2591
+ # 超时重试必须有新变量才值得做(2026-09-22):提示词与上次逐字节
2592
+ # 相同、上次又是超时/停滞收场时,重试只是把同一个超时再烧一遍
2593
+ # (2400s × N)。此时直接跳出交替换将——换 CLI/模型才是新机会。
2594
+ # 注意保留「缩上下文重试」:提示词真的变小了(缩块生效)就照试,
2595
+ # 那是针对「提示词过大挂起」的有效降级。
2596
+ if prev_timed_out and use_prompt == prev_prompt:
2597
+ break
2514
2598
  res = _run_step(run_id, "draft-c%d" % i, modelhub.bind_agent(impl, difficulty), use_prompt,
2515
2599
  step_wd, readonly=False, ev=ev, timeout=2400,
2516
2600
  resume=resume_ctx["session"] if resume_ctx else None,
2517
2601
  images=_task_images(task, workdir),
2518
2602
  note=("起草重试 %d/2(网关限流退避)" % draft_attempt) if draft_attempt else "")
2603
+ prev_prompt = use_prompt
2604
+ prev_timed_out = bool((res.get("raw") or {}).get("timed_out"))
2519
2605
  good, txt = _chapter_state()
2520
2606
  if good:
2521
2607
  break
@@ -2836,9 +2922,23 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2836
2922
  global_issues = []
2837
2923
 
2838
2924
  def run_global_round(agent_list):
2839
- """一轮全局评审:返回 (出分评审数, 按维累计分)。失败/不可解析不得当成低分计入。"""
2925
+ """一轮全局评审:返回 (出分评审数, 按维累计分)。失败/不可解析不得当成低分计入。
2926
+
2927
+ 多评审并发(2026-09-22,同章级评审并发):各自读同一份全书文本、互不依赖,
2928
+ 串行只是把等待时间叠起来。共享结构仍在 join 后按原顺序合并,结果与串行一致。"""
2840
2929
  gmeans_acc, scored = {}, 0
2841
- for agent in agent_list:
2930
+ results = {}
2931
+ # 绑定解析提前到主线程(线程内不做会写盘的 bind_agent)
2932
+ bound = [modelhub.bind_agent(a, difficulty) for a in agent_list]
2933
+
2934
+ def _global_one(idx, agent):
2935
+ """异常不吞:记进 results 由主线程按原顺序重抛(保留串行语义)。"""
2936
+ try:
2937
+ _global_one_inner(idx, agent)
2938
+ except BaseException as exc: # noqa: BLE001(含 Cancelled)
2939
+ results[idx] = {"error": exc}
2940
+
2941
+ def _global_one_inner(idx, agent):
2842
2942
  if agent.get("mode") == "mock":
2843
2943
  step, _ = store.add_step(run_id, "global-critique", agent["id"],
2844
2944
  agent.get("label"))
@@ -2847,25 +2947,41 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2847
2947
  "issues": [], "summary": "(mock)全书结构完整,达到可签约水平"}
2848
2948
  store.finish_step(run_id, step["n"], "done", summary="均分 8.0:(mock)全书达标",
2849
2949
  duration_s=0.15)
2950
+ results[idx] = gj
2951
+ return
2952
+ gtpl = SERIAL_GLOBAL_PROMPT
2953
+ if bible:
2954
+ gtpl = gtpl.replace("## 全书目标", bible + "\n\n## 全书目标", 1)
2955
+ res = _run_step(run_id, "global-critique", bound[idx],
2956
+ (gtpl.replace("__DIMKEYS__", dimkey)
2957
+ .replace("__GOAL__", task["goal"])
2958
+ .replace("__MANUSCRIPT__", full_text[:60000])),
2959
+ workdir, readonly=True, ev=ev, timeout=2400)
2960
+ results[idx] = _critique_json(res, dims)
2961
+
2962
+ threads = []
2963
+ for idx, agent in enumerate(agent_list):
2964
+ th = threading.Thread(target=_global_one, args=(idx, agent),
2965
+ name="gcrit-%s-%d" % (run_id, idx), daemon=True)
2966
+ threads.append(th)
2967
+ th.start()
2968
+ for th in threads:
2969
+ th.join(3000)
2970
+ _check_cancel(ev)
2971
+
2972
+ for idx in range(len(agent_list)):
2973
+ gj = results.get(idx)
2974
+ if gj is None:
2975
+ continue
2976
+ if "error" in gj:
2977
+ raise gj["error"] # 还原串行版的异常上抛语义
2978
+ if gj.get("scores"):
2850
2979
  scored += 1
2851
- else:
2852
- gtpl = SERIAL_GLOBAL_PROMPT
2853
- if bible:
2854
- gtpl = gtpl.replace("## 全书目标", bible + "\n\n## 全书目标", 1)
2855
- res = _run_step(run_id, "global-critique", modelhub.bind_agent(agent, difficulty),
2856
- (gtpl.replace("__DIMKEYS__", dimkey)
2857
- .replace("__GOAL__", task["goal"])
2858
- .replace("__MANUSCRIPT__", full_text[:60000])),
2859
- workdir, readonly=True, ev=ev, timeout=2400)
2860
- gj = _critique_json(res, dims)
2861
- if gj.get("scores"):
2862
- scored += 1
2863
2980
  global_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
2864
2981
  for d in dims:
2865
2982
  v = gj.get("scores", {}).get(d)
2866
2983
  if v is not None:
2867
2984
  gmeans_acc.setdefault(d, []).append(float(v))
2868
- _check_cancel(ev)
2869
2985
  return scored, gmeans_acc
2870
2986
 
2871
2987
  gscored, gmeans_acc = run_global_round(critics)
@@ -3359,8 +3475,8 @@ def _run_content_review(run, task, agents, ev, stats, mode):
3359
3475
  if task.get("context"):
3360
3476
  crit_prompt += "\n\n## 原始任务背景与附件参考\n" + task["context"]
3361
3477
  # AI 味确定性检测(借鉴 oh-story 去AI味):客观参考线随评审下发,
3362
- # 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分。
3363
- crit_prompt = _inject_detection(crit_prompt, manuscript, task)
3478
+ # 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分
3479
+ crit_prompt = aiflavor.inject_into_prompt(crit_prompt, manuscript, task.get("type"))
3364
3480
  for agent in critics:
3365
3481
  role = "critique-r%d" % r
3366
3482
  if agent.get("mode") == "mock":
@@ -114,13 +114,16 @@ def _log_usage(source, role, task, res, agent=None, tool="", model="", provider=
114
114
  except Exception:
115
115
  pass
116
116
 
117
- CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
118
- 并判定任务难度和后续质量步骤。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
117
+ CODE_PLAN_PROMPT = """你是技术负责人。请先判定任务难度,再按难度拆解为按顺序执行的子任务,
118
+ 并给出后续质量步骤。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
119
119
  {"difficulty": "easy 或 hard", "workflow": {"review_required": true, "max_repair_rounds": 0, "allow_switch": false}, "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令", "files": ["涉及的文件路径"]}]}
120
- 难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
120
+ 难度判定:宁易勿难,绝大多数任务都是 easy——常规增删改查/小函数/格式调整/单点 bug 修复/
121
+ 配置调整 = easy;只有跨模块改动/架构调整/复杂算法/安全相关才算 hard,拿不准时一律按 easy。
121
122
  质量步骤判定:涉及安全、数据迁移、并发、权限、公共 API 或跨模块改动时必须评审;
122
123
  有明确自动验收的低风险小改可不做模型评审。max_repair_rounds 取 0-2,只有复杂任务才建议换将。
123
- 子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
124
+ 子任务拆分:easy 一律只拆 1 个子任务——一个子任务把目标完整做完并自检通过即收尾,
125
+ 禁止拆出独立的核查/联调/收尾子任务;只有 hard 才允许多个子任务(至多 __N__ 个),
126
+ 粒度要可独立验证,且最后一个子任务必须包含整体联调/收尾。
124
127
 
125
128
  ## 先探索再计划(重要,借鉴 OpenSpec explore)
126
129
  拆解之前先用你的读文件/搜索工具**实际查看工作目录**,找到目标相关的真实文件与函数,再据此拆解。
@@ -527,7 +530,13 @@ def _norm_subtasks(data):
527
530
  if clean:
528
531
  step["files"] = clean
529
532
  steps.append(step)
530
- return steps or None
533
+ if not steps:
534
+ return None
535
+ # easy 任务执行层钉死单子任务:编排者提示词不服从多拆时,截掉只留首步
536
+ # (联调/收尾要求仅对 hard 生效,easy 单步自检即收尾)。
537
+ if _plan_difficulty(data) == "easy":
538
+ steps = steps[:1]
539
+ return steps
531
540
 
532
541
 
533
542
  def _plan_difficulty(data):