codebee 0.1.69 → 0.1.70

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -3
  3. package/app/browser-final-1440.png +0 -0
  4. package/app/browser-final-390.png +0 -0
  5. package/app/browser-google-fallback.png +0 -0
  6. package/app/browser-google-test.png +0 -0
  7. package/app/browser-workbench-1440.png +0 -0
  8. package/app/browser-workbench-390.png +0 -0
  9. package/app/browser-workspace-google.png +0 -0
  10. package/app/browser-workspace-smoke.png +0 -0
  11. package/app/browser-workspace.png +0 -0
  12. package/app/core/audit.py +30 -0
  13. package/app/core/automation.py +107 -12
  14. package/app/core/benchstore.py +95 -0
  15. package/app/core/bookmeta.py +9 -4
  16. package/app/core/builtin_agent.py +472 -33
  17. package/app/core/catalog.py +7 -1
  18. package/app/core/dispatch.py +20 -3
  19. package/app/core/error_codes.py +11 -2
  20. package/app/core/evalbench.py +720 -0
  21. package/app/core/flows.py +228 -3
  22. package/app/core/jobs.py +22 -1
  23. package/app/core/manager.py +21 -0
  24. package/app/core/market_remote.py +10 -0
  25. package/app/core/mcp_client.py +287 -0
  26. package/app/core/modelhub.py +185 -2
  27. package/app/core/notify.py +118 -23
  28. package/app/core/novel_quality.py +189 -0
  29. package/app/core/paths.py +4 -0
  30. package/app/core/pipeline.py +426 -28
  31. package/app/core/planner.py +243 -22
  32. package/app/core/plugins.py +525 -0
  33. package/app/core/publish/browser.py +129 -13
  34. package/app/core/publish/fanqie.py +6 -0
  35. package/app/core/publish/flow.py +154 -5
  36. package/app/core/publish/flows-fanqie-calibrated.json +11 -7
  37. package/app/core/publish/ledger.py +29 -3
  38. package/app/core/publish/manager.py +134 -2
  39. package/app/core/publish/qimao.py +5 -0
  40. package/app/core/remotesync.py +121 -0
  41. package/app/core/runner.py +80 -5
  42. package/app/core/settings.py +39 -2
  43. package/app/core/settings_schema.py +8 -0
  44. package/app/core/store.py +30 -8
  45. package/app/core/usage.py +187 -14
  46. package/app/core/zentao.py +153 -4
  47. package/app/current-browser.png +0 -0
  48. package/app/main.py +279 -15
  49. package/app/mcp_server.py +175 -0
  50. package/app/pet.py +82 -21
  51. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  52. package/app/plugins/code-review-helper/skills/review.md +16 -0
  53. package/app/ui/app.js +1380 -73
  54. package/app/ui/i18n.js +248 -6
  55. package/app/ui/index.html +277 -13
  56. package/app/ui/style.css +786 -0
  57. package/package.json +2 -1
@@ -13,10 +13,13 @@
13
13
  from __future__ import annotations
14
14
 
15
15
  import base64
16
+ import fnmatch
16
17
  import io
17
18
  import json
18
19
  import mimetypes
19
20
  import os
21
+ import re
22
+ import shutil
20
23
  import threading
21
24
  import time
22
25
  import urllib.parse
@@ -25,7 +28,7 @@ from pathlib import Path
25
28
 
26
29
  from . import modelhub, runner, tlsctx
27
30
 
28
- MAX_TOOL_ITERS = 16 # 单步工具循环上限(防模型打转)
31
+ MAX_TOOL_ITERS = 32 # 单步工具循环上限(防模型打转;收尾另留 1 轮)
29
32
  READ_MAX_BYTES = 64 * 1024 # read_file 单次读取上限
30
33
  READ_HEAD_BYTES = 44 * 1024 # TokenJuice 借鉴(openhuman):超限文件头尾保留、
31
34
  READ_TAIL_BYTES = 16 * 1024 # 中段省略——日志/代码的报错常在尾部,纯截头会丢关键信息
@@ -50,13 +53,19 @@ _SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户
50
53
 
51
54
  ## 工作方式
52
55
  - 需要读文件、看目录、写文件时调用工具;所有路径都是工作目录内的相对路径。
56
+ - 改文件的优先级:小改动用 edit_file(精确查找替换,不必整份重写)>追加用 append_file>整份重建才用 write_file。删除/移动/重命名/复制用 fs_manage,不要绕 shell。
57
+ - 大文件(超 64KB)默认只读头尾、中段省略;要看全用 read_file 的 offset/lines 分段读(返回头会提示下一段的 offset)。找内容用 search_content(带行号的 grep),找文件用 find_files,不要逐个文件读。
53
58
  - read_file 只能读文本文件;图片、压缩包等二进制文件读不了,如实告知用户即可,不要反复尝试。
54
59
  - 用户消息中的图片附件会直接出现在对话里,可直接看图作答,无需用工具读取。
55
60
  - run_command 直接在用户的电脑上执行命令并回传退出码与输出:诊断、修复、改配置、重启服务等操作会真实生效。命令跑完看输出再决定下一步,不要一次性罗列步骤让用户自己敲。
61
+ - 当工具列表中提供 create_task 时,如果用户明确要求开始另一项 CodeBee 工作(例如“新建连载小说任务”“再开一个代码任务”),调用它发起独立任务;不要只在当前回答里描述应该怎么做。先从用户话里提取任务类型和目标,缺少关键目标时再追问。
56
62
  - Windows 上需要管理员权限的命令(flushdns、winsock reset、防火墙、系统服务等),用 powershell -Command "Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait" 触发 UAC——用户屏幕会弹窗,点允许即提权执行;提权进程的输出拿不到,之后要用普通命令复核效果。macOS/Linux 用 sudo 并在输出里提示用户输密码不可行时改写临时脚本让用户跑。
57
63
  - 避免跑长驻/交互式命令(ping -t、top、要按键应答的安装器),它们会拖满超时被强杀。
58
64
  - 产出文件一律 UTF-8 编码。
59
- - 回答用户的语言与用户一致(默认中文)。直接给结论和内容,不要输出任何机器标记或协议行。"""
65
+ - 回答用户的语言与用户一致(默认中文)。直接给结论和内容,不要输出任何机器标记或协议行。
66
+ - 核心产出完成后立即停止探索并总结;不要为“再确认一次”重复读取或重复写入。
67
+ - 每次工具调用后都要判断是否已经达到目标;如果工具轮次即将耗尽,先完成已开始的操作,随后直接给出已完成内容、产出文件和未完成项。
68
+ """
60
69
 
61
70
 
62
71
  def resolve(provider_id="", model="", difficulty="default"):
@@ -286,6 +295,35 @@ def _tool_read_file(workdir, args):
286
295
  return "(路径越界: %s)" % rel
287
296
  if not p.is_file():
288
297
  return "(文件不存在: %s)" % rel
298
+ # 分段读取(P0 基础件):offset/lines 任一非空即按行号范围读——超 64KB
299
+ # 被「头尾保留、中段省略」截断的大文件靠它读全(用户实测 _lessons_dump.txt
300
+ # 68KB 被截案)。行号 1-based;逐行切片后不强行拼回整份。
301
+ offset_raw = str(args.get("offset") or "").strip()
302
+ lines_raw = str(args.get("lines") or "").strip()
303
+ if offset_raw or lines_raw:
304
+ try:
305
+ size = p.stat().st_size
306
+ except OSError as e:
307
+ return "(读取失败: %s)" % e
308
+ if size > READ_EDIT_MAX_BYTES:
309
+ return ("(文件过大 %d MB,分段读取上限 %d MB;请用 run_command 处理)"
310
+ % (size // (1024 * 1024), READ_EDIT_MAX_BYTES // (1024 * 1024)))
311
+ try:
312
+ offset = max(1, int(float(offset_raw or 1)))
313
+ lines = max(1, min(READ_SEGMENT_MAX_LINES,
314
+ int(float(lines_raw or READ_SEGMENT_DEFAULT_LINES))))
315
+ except (TypeError, ValueError):
316
+ return "(offset/lines 须为整数行号)"
317
+ all_lines = runner.decode_output(p.read_bytes()).splitlines()
318
+ total = len(all_lines)
319
+ if total == 0:
320
+ return "(空文件)"
321
+ if offset > total:
322
+ return "(offset %d 超出文件总行数 %d)" % (offset, total)
323
+ seg = all_lines[offset - 1:offset - 1 + lines]
324
+ head = "第 %d–%d 行(共 %d 行;用 offset=%d 续读下一段)" % (
325
+ offset, offset - 1 + len(seg), total, offset + len(seg))
326
+ return head + "\n" + ("\n".join(seg) or "(该范围无内容)")
289
327
  data = p.read_bytes()[:READ_MAX_BYTES + 1]
290
328
  truncated = len(data) > READ_MAX_BYTES
291
329
  if truncated:
@@ -343,7 +381,10 @@ def _tool_write_file(workdir, args):
343
381
  if base not in dest.parents:
344
382
  return "(路径越界: %s)" % rel
345
383
  dest.parent.mkdir(parents=True, exist_ok=True)
346
- dest.write_text(content, encoding="utf-8")
384
+ # newline="":不翻译换行——模型回写 read_file 读到的 CRLF 内容时,
385
+ # 默认翻译会二次翻成 \r\r\n
386
+ with dest.open("w", encoding="utf-8", newline="") as fh:
387
+ fh.write(content)
347
388
  return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
348
389
 
349
390
 
@@ -388,12 +429,254 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None):
388
429
  return "%s退出码: %s\n%s" % (prefix, "未知" if code is None else code, body)
389
430
 
390
431
 
432
+ # ---------------------------------------------------------------- P0 基础件扩展
433
+ # 2026-09-25 用户实测反馈:改大文件只能整份读写、删/移/改名只能绕 shell、
434
+ # 大文件 64KB 中段被截、找内容只能逐文件读——补齐四个基础件(局部编辑、
435
+ # 文件管理、分段读取、全文搜索),全部锁死工作目录内。
436
+
437
+ READ_EDIT_MAX_BYTES = 16 * 1024 * 1024 # edit/append 分段读全文解码上限
438
+ SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024 # search_content 单文件参与搜索上限
439
+ SEARCH_DEFAULT_RESULTS = 50 # 命中行默认上限
440
+ READ_SEGMENT_DEFAULT_LINES = 200 # read_file 分段默认行数
441
+ READ_SEGMENT_MAX_LINES = 2000 # read_file 分段行数上限
442
+
443
+ _WALK_PRUNE = {".git", "__pycache__", "node_modules", ".venv", "venv",
444
+ "dist", "build", "target", ".idea", ".vscode", "__MACOSX",
445
+ ".pytest_cache", ".mypy_cache"}
446
+
447
+
448
+ def _guard_rel(workdir, rel, allow_root=False):
449
+ """工作目录相对路径守卫(list/read/write 同款惯用法抽公共)。
450
+
451
+ 返回 (resolve 后的 Path, 错误串);合法时错误串为 None。空 rel 仅在
452
+ allow_root=True 时代表工作目录根。"""
453
+ rel = str(rel or "").replace("\\", "/").strip("/")
454
+ if (not rel and not allow_root) or ".." in Path(rel).parts \
455
+ or any(":" in seg for seg in Path(rel).parts):
456
+ return None, "(非法路径: %s)" % rel
457
+ base = Path(workdir or ".").resolve()
458
+ p = (base / rel).resolve() if rel else base
459
+ if base not in p.parents and p != base:
460
+ return None, "(路径越界: %s)" % rel
461
+ return p, None
462
+
463
+
464
+ def _read_text_capped(p, limit=READ_EDIT_MAX_BYTES):
465
+ """读文本并识别编码(bytes 上限防巨型文件)。返回 (text, err)。"""
466
+ try:
467
+ size = p.stat().st_size
468
+ except OSError as e:
469
+ return "", "(读取失败: %s)" % e
470
+ if size > limit:
471
+ return "", ("(文件过大 %d MB,超过 %d MB 上限;请用 run_command 处理)"
472
+ % (size // (1024 * 1024), limit // (1024 * 1024)))
473
+ data = p.read_bytes()
474
+ if _looks_binary(data[:8192]):
475
+ return "", "(二进制文件,%s 不适用: %s)" % ("文本编辑", _bin_format(data[:64]))
476
+ return runner.decode_output(data), None
477
+
478
+
479
+ def _tool_edit_file(workdir, args):
480
+ """精确查找替换:old_text 逐字符匹配,默认仅允许 1 处命中。"""
481
+ p, err = _guard_rel(workdir, args.get("path"))
482
+ if err:
483
+ return err
484
+ old_text = str(args.get("old_text") or "")
485
+ new_text = str(args.get("new_text") if args.get("new_text") is not None else "")
486
+ if not old_text:
487
+ return "(old_text 不能为空;改全文请用 write_file 整份写入)"
488
+ if not p.is_file():
489
+ return "(文件不存在: %s)" % p.name
490
+ text, err = _read_text_capped(p)
491
+ if err:
492
+ return err
493
+ count = text.count(old_text)
494
+ crlf = False
495
+ if count == 0 and "\r\n" in text and "\r\n" not in old_text:
496
+ # Windows 文件 CRLF:按 LF 形态重试匹配,命中则在原文上以 CRLF 替换,
497
+ # 保持文件既有换行风格(否则整个文件换行符被翻转)
498
+ crlf_text = text.replace("\r\n", "\n")
499
+ count = crlf_text.count(old_text)
500
+ crlf = count > 0
501
+ if count == 0:
502
+ return ("(old_text 在文件中找不到逐字符匹配——read_file 超过 64KB 会"
503
+ "中段省略,请用 offset/lines 分段读全后重试)")
504
+ replace_all = str(args.get("replace_all") or "").strip().lower() in ("1", "true", "yes")
505
+ if count > 1 and not replace_all:
506
+ return ("(old_text 命中 %d 处;请补充更多上下文使命中唯一,"
507
+ "或传 replace_all=true 全部替换)" % count)
508
+ if crlf:
509
+ old_text = old_text.replace("\n", "\r\n")
510
+ new_text = new_text.replace("\n", "\r\n")
511
+ # newline="" 禁止 Python 的 \n→os.linesep 翻译:文件既有 CRLF 时会被
512
+ # 二次翻译成 \r\r\n(Windows 实测损坏案),写什么落什么
513
+ with p.open("w", encoding="utf-8", newline="") as fh:
514
+ fh.write(text.replace(old_text, new_text))
515
+ return "已替换 %d 处(%s)" % (count if replace_all else 1, p.name)
516
+
517
+
518
+ def _tool_append_file(workdir, args):
519
+ """文件末尾追加(bytes 级追加,大文件不整份重写);不存在则创建。"""
520
+ p, err = _guard_rel(workdir, args.get("path"))
521
+ if err:
522
+ return err
523
+ content = str(args.get("content") if args.get("content") is not None else "")
524
+ if not content:
525
+ return "(content 不能为空)"
526
+ if p.is_file():
527
+ with open(p, "rb") as fh:
528
+ head = fh.read(8192)
529
+ if _looks_binary(head):
530
+ return "(二进制文件,不能按文本追加: %s — %s)" % (p.name, _bin_format(head))
531
+ p.parent.mkdir(parents=True, exist_ok=True)
532
+ with open(p, "ab") as fh:
533
+ fh.write(content.encode("utf-8"))
534
+ return "已追加 %d 字符到 %s(UTF-8)" % (len(content), p.name)
535
+
536
+
537
+ def _tool_fs_manage(workdir, args):
538
+ """删除 / 移动 / 重命名 / 复制(工作目录内;目标已存在一律拒绝防覆盖)。"""
539
+ action = str(args.get("action") or "").strip().lower()
540
+ if action not in ("delete", "move", "rename", "copy"):
541
+ return "(action 须为 delete/move/rename/copy)"
542
+ p, err = _guard_rel(workdir, args.get("path"))
543
+ if err:
544
+ return err
545
+ if not p.exists() and not p.is_symlink():
546
+ return "(不存在: %s)" % p
547
+ if action == "delete":
548
+ if p.is_dir() and not p.is_symlink():
549
+ shutil.rmtree(str(p))
550
+ return "已删除目录(含全部内容): %s" % p.name
551
+ p.unlink()
552
+ return "已删除: %s" % p.name
553
+ dest, err = _guard_rel(workdir, args.get("dest"))
554
+ if err:
555
+ return err.replace("非法路径", "dest 非法路径").replace("路径越界", "dest 路径越界")
556
+ if dest.exists() or dest.is_symlink():
557
+ return "(目标已存在,拒绝覆盖: %s;先删除或换名)" % dest.name
558
+ dest.parent.mkdir(parents=True, exist_ok=True)
559
+ if action == "copy":
560
+ if p.is_dir():
561
+ shutil.copytree(str(p), str(dest))
562
+ else:
563
+ shutil.copy2(str(p), str(dest))
564
+ return "已复制 %s → %s" % (p.name, dest.name)
565
+ shutil.move(str(p), str(dest))
566
+ return "已%s %s → %s" % ("移动" if action == "move" else "重命名",
567
+ p.name, dest.name)
568
+
569
+
570
+ def _tool_search_content(workdir, args):
571
+ """grep 式内容搜索:正则(编译失败回退字面量),逐行命中带行号。"""
572
+ pattern = str(args.get("pattern") or "").strip()
573
+ if not pattern:
574
+ return "(pattern 不能为空)"
575
+ try:
576
+ rx = re.compile(pattern)
577
+ except re.error:
578
+ rx = re.compile(re.escape(pattern))
579
+ pattern_note = "(正则无效,已按字面量搜索)\n"
580
+ else:
581
+ pattern_note = ""
582
+ sub, err = _guard_rel(workdir, args.get("path"), allow_root=True)
583
+ if err:
584
+ return err
585
+ if not sub.is_dir():
586
+ return "(目录不存在: %s)" % args.get("path")
587
+ glob_pat = str(args.get("glob") or "").strip()
588
+ try:
589
+ max_results = max(1, min(200, int(float(args.get("max_results") or 0)
590
+ or SEARCH_DEFAULT_RESULTS)))
591
+ except (TypeError, ValueError):
592
+ max_results = SEARCH_DEFAULT_RESULTS
593
+ base = Path(workdir or ".").resolve()
594
+ out, hits, scanned = [], 0, 0
595
+ for root, dirs, files in os.walk(str(sub)):
596
+ dirs[:] = [d for d in dirs if d not in _WALK_PRUNE]
597
+ for f in files:
598
+ if glob_pat:
599
+ relp = os.path.relpath(os.path.join(root, f), str(base)).replace(os.sep, "/")
600
+ if not (fnmatch.fnmatch(f, glob_pat) or fnmatch.fnmatch(relp, glob_pat)):
601
+ continue
602
+ fp = os.path.join(root, f)
603
+ try:
604
+ if os.path.getsize(fp) > SEARCH_MAX_FILE_BYTES:
605
+ continue
606
+ with open(fp, "rb") as fh:
607
+ head = fh.read(8192)
608
+ if _looks_binary(head):
609
+ continue
610
+ with open(fp, "rb") as fh:
611
+ data = fh.read()
612
+ except OSError:
613
+ continue
614
+ scanned += 1
615
+ rel = os.path.relpath(fp, str(base)).replace(os.sep, "/")
616
+ for i, line in enumerate(runner.decode_output(data).splitlines(), 1):
617
+ if rx.search(line):
618
+ out.append("%s:%d: %s" % (rel, i, line.strip()[:200]))
619
+ hits += 1
620
+ if hits >= max_results:
621
+ out.append("…(已达 %d 行上限,缩小 pattern/glob 或调大 max_results)"
622
+ % max_results)
623
+ return pattern_note + "\n".join(out)
624
+ if not out:
625
+ return pattern_note + ("(无命中;已扫描 %d 个文本文件)" % scanned)
626
+ return pattern_note + "\n".join(out) + "\n(共 %d 行命中 / 扫描 %d 个文件)" % (hits, scanned)
627
+
628
+
629
+ def _tool_find_files(workdir, args):
630
+ """文件名通配查找(fnmatch;pattern 不含 / 时匹配任意深度的文件名)。"""
631
+ pattern = str(args.get("pattern") or "").strip().replace("\\", "/")
632
+ if not pattern or ".." in pattern or ":" in pattern:
633
+ return "(非法 pattern: %s;示例 *.py 或 data/*.json)" % pattern
634
+ base = Path(workdir or ".").resolve()
635
+ match_name = "/" not in pattern
636
+ out = []
637
+ for root, dirs, files in os.walk(str(base)):
638
+ dirs[:] = [d for d in dirs if d not in _WALK_PRUNE]
639
+ for f in files:
640
+ rel = os.path.relpath(os.path.join(root, f), str(base)).replace(os.sep, "/")
641
+ if not (fnmatch.fnmatch(rel, pattern)
642
+ or (match_name and fnmatch.fnmatch(f, pattern))):
643
+ continue
644
+ try:
645
+ size = os.path.getsize(os.path.join(root, f))
646
+ except OSError:
647
+ size = -1
648
+ out.append("%s (%d B)" % (rel, size))
649
+ if len(out) >= LIST_MAX_ENTRIES:
650
+ return "\n".join(out) + "\n…(截断,共 %d+ 项)" % LIST_MAX_ENTRIES
651
+ return "\n".join(out) or "(无匹配文件)"
652
+
653
+
391
654
  TOOLS_SPEC = [
392
655
  {"name": "list_files", "description": "列出工作目录(或其子目录)下的文件",
393
656
  "args": {"path": "子目录相对路径,留空=根目录"}},
394
- {"name": "read_file", "description": "读取工作目录内一个文本文件(UTF-8/GBK 自动识别,超 64KB 时保留开头与结尾、中段省略;图片等二进制文件无法读取)",
395
- "args": {"path": "文件相对路径"}},
396
- {"name": "write_file", "description": "把文本内容写入工作目录内一个文件(UTF-8,父目录自动创建)",
657
+ {"name": "read_file", "description": "读取工作目录内一个文本文件(UTF-8/GBK 自动识别;默认超 64KB 时保留开头与结尾、中段省略,此时用 offset/lines 分段读全;图片等二进制文件无法读取)",
658
+ "args": {"path": "文件相对路径",
659
+ "?offset": "可选,起始行号(1-based),配合 lines 分段读取大文件",
660
+ "?lines": "可选,读取行数(默认 200,最大 2000)"}},
661
+ {"name": "edit_file", "description": "对文件做精确查找替换(局部编辑,不必整份重写):old_text 必须与文件内容逐字符一致,默认要求命中唯一,配合 read_file 使用",
662
+ "args": {"path": "文件相对路径",
663
+ "old_text": "要被替换的原文(逐字符精确匹配)",
664
+ "new_text": "替换后的新文本(可为空串=删除)",
665
+ "?replace_all": "可选,true=替换全部命中(默认仅允许 1 处命中)"}},
666
+ {"name": "append_file", "description": "把文本追加到文件末尾(bytes 级追加,大文件不必整份重写);文件不存在则创建。追加日志、逐条记录时优先用它",
667
+ "args": {"path": "文件相对路径", "content": "追加到末尾的文本"}},
668
+ {"name": "fs_manage", "description": "文件管理:删除 / 移动 / 重命名 / 复制(工作目录内;目标已存在一律拒绝覆盖,删目录会连内容一起删)",
669
+ "args": {"action": "delete | move | rename | copy",
670
+ "path": "源相对路径(目录或文件)",
671
+ "?dest": "move/rename/copy 的目标相对路径"}},
672
+ {"name": "search_content", "description": "全文内容搜索(grep 式):在工作目录内逐文件逐行匹配正则,命中行带「路径:行号:」前缀返回;自动跳过二进制与依赖目录(node_modules/.git 等)",
673
+ "args": {"pattern": "搜索文本或正则表达式",
674
+ "?path": "可选,限定搜索的子目录,默认根目录",
675
+ "?glob": "可选,文件名过滤,如 *.py",
676
+ "?max_results": "可选,命中行数上限(默认 50,最大 200)"}},
677
+ {"name": "find_files", "description": "按文件名通配查找文件(如 *.py、data/*.json、report*.md);pattern 不含 / 时匹配任意深度的文件名",
678
+ "args": {"pattern": "文件名通配模式"}},
679
+ {"name": "write_file", "description": "把文本内容写入工作目录内一个文件(UTF-8,整份覆盖,父目录自动创建;只改一小段请用 edit_file)",
397
680
  "args": {"path": "文件相对路径", "content": "完整文本内容"}},
398
681
  {"name": "run_command",
399
682
  "description": "在本机执行一条 shell 命令,返回退出码与合并输出。诊断、修复、改配置、重启服务等操作真实生效;先跑命令看结果再定下一步。默认超时 600 秒,可用 timeout_sec 调整(5~1800)。Windows 需要管理员权限的命令(flushdns/防火墙/系统服务等)用 powershell -Command \"Start-Process <程序> -ArgumentList '<参数>' -Verb RunAs -Wait\" 触发 UAC(用户点允许后提权执行;提权进程输出拿不到,事后用普通命令复核)。避免长驻/交互式命令(ping -t、top、按键应答的安装器)。",
@@ -402,10 +685,78 @@ TOOLS_SPEC = [
402
685
  ]
403
686
 
404
687
  _TOOL_IMPL = {"list_files": _tool_list_files, "read_file": _tool_read_file,
405
- "write_file": _tool_write_file, "run_command": _tool_run_command}
406
-
407
-
408
- def _exec_tool(workdir, name, args, cancel_event=None, deadline=None):
688
+ "write_file": _tool_write_file, "run_command": _tool_run_command,
689
+ "edit_file": _tool_edit_file, "append_file": _tool_append_file,
690
+ "fs_manage": _tool_fs_manage, "search_content": _tool_search_content,
691
+ "find_files": _tool_find_files}
692
+
693
+
694
+ CREATE_TASK_SPEC = {
695
+ "name": "create_task",
696
+ "description": "发起一个独立的 CodeBee 任务。用户要求开始另一类工作(例如创建连载小说、写代码或生成文档)时使用;任务会出现在任务列表并异步运行,不要把它当作当前对话里的普通回答。",
697
+ "args": {
698
+ "type": "任务类型 ID,例如 serial_novel、novel、code、doc、article",
699
+ "goal": "新任务的明确目标",
700
+ "?title": "新任务标题",
701
+ "?workdir": "绝对工作目录;留空沿用当前工作目录",
702
+ "?context": "补充背景或约束",
703
+ "?chapters": "serial_novel 首批章节数",
704
+ "?words_per_chapter": "serial_novel 每章目标字数",
705
+ "?start_chapter": "续写起始章节号",
706
+ "?variants": "连载同章候选稿数量(1-3)",
707
+ "?branches": "连载剧情分支数量(1-3)",
708
+ "?story_bible": "连载小说初始故事圣经",
709
+ },
710
+ }
711
+
712
+
713
+ def _tool_create_task(workdir, args, task_creator=None):
714
+ """Create a separate task through the caller-owned task boundary."""
715
+ if task_creator is None:
716
+ return "(当前对话不支持发起新任务)"
717
+ task_type = str(args.get("type") or "").strip().lower()
718
+ goal = str(args.get("goal") or "").strip()
719
+ if not task_type:
720
+ return "(type 不能为空;可用类型请使用 serial_novel、novel、code、doc 等)"
721
+ if not goal:
722
+ return "(goal 不能为空)"
723
+ payload = {
724
+ "type": task_type,
725
+ "goal": goal,
726
+ "title": str(args.get("title") or "").strip(),
727
+ "workdir": str(args.get("workdir") or workdir or "").strip(),
728
+ "context": str(args.get("context") or "").strip(),
729
+ }
730
+ if task_type == "serial_novel":
731
+ serial = {}
732
+ for key in ("chapters", "words_per_chapter", "start_chapter", "variants", "branches"):
733
+ if args.get(key) not in (None, ""):
734
+ serial[key] = args.get(key)
735
+ if serial:
736
+ payload["serial"] = serial
737
+ if args.get("story_bible") not in (None, ""):
738
+ payload["story_bible"] = str(args.get("story_bible") or "").strip()
739
+ try:
740
+ result = task_creator(payload)
741
+ except Exception as exc:
742
+ return "(发起任务失败:%s)" % str(exc)[:300]
743
+ if not isinstance(result, dict) or not result.get("task_id"):
744
+ return "(发起任务失败:未返回任务编号)"
745
+ return "已发起新任务:%s(运行编号:%s,类型:%s)" % (
746
+ result["task_id"], result.get("run_id") or "", task_type)
747
+
748
+
749
+ def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creator=None):
750
+ if name == "create_task":
751
+ return _tool_create_task(workdir, args or {}, task_creator=task_creator)
752
+ if str(name or "").startswith("mcp__"):
753
+ # MCP 工具:透传给配置的服务器(stdio JSON-RPC);超时给足但封顶
754
+ try:
755
+ from . import mcp_client
756
+ r = mcp_client.dispatch_full_name(name, args or {})
757
+ return r.get("text") or ("(MCP 工具失败: %s)" % r.get("error") if not r.get("ok") else "")
758
+ except Exception as e:
759
+ return "MCP 工具执行失败: %s" % e
409
760
  fn = _TOOL_IMPL.get(name or "")
410
761
  if fn is None:
411
762
  return "(未知工具: %s)" % name
@@ -419,23 +770,55 @@ def _exec_tool(workdir, name, args, cancel_event=None, deadline=None):
419
770
 
420
771
  # ---------------------------------------------------------------- 协议适配
421
772
 
422
- def _openai_tools():
423
- return [{"type": "function", "function": {
424
- "name": t["name"], "description": t["description"],
425
- "parameters": {"type": "object",
426
- "properties": {k: {"type": "string", "description": v}
427
- for k, v in t["args"].items()},
428
- "required": list(t["args"].keys())}}}
429
- for t in TOOLS_SPEC]
773
+ def _split_tool_args(t):
774
+ """args 键以 ? 开头 = 可选参数(模型侧名字去掉 ?,不进 required)。"""
775
+ props, required = {}, []
776
+ for k, v in t["args"].items():
777
+ opt = k.startswith("?")
778
+ props[k[1:] if opt else k] = {"type": "string", "description": v}
779
+ if not opt:
780
+ required.append(k)
781
+ return props, required
430
782
 
431
783
 
432
- def _anthropic_tools():
433
- return [{"name": t["name"], "description": t["description"],
434
- "input_schema": {"type": "object",
435
- "properties": {k: {"type": "string", "description": v}
436
- for k, v in t["args"].items()},
437
- "required": list(t["args"].keys())}}
438
- for t in TOOLS_SPEC]
784
+ def _mcp_specs():
785
+ """MCP 工具清单(带缓存);取不到/未配置返回空表——增强不挡主流程。"""
786
+ try:
787
+ from . import mcp_client
788
+ return mcp_client.tool_specs_cached()
789
+ except Exception:
790
+ return []
791
+
792
+
793
+ def _openai_tools(extra_specs=None):
794
+ out = []
795
+ for t in list(TOOLS_SPEC) + list(extra_specs or []):
796
+ props, required = _split_tool_args(t)
797
+ out.append({"type": "function", "function": {
798
+ "name": t["name"], "description": t["description"],
799
+ "parameters": {"type": "object", "properties": props,
800
+ "required": required}}})
801
+ for m in _mcp_specs():
802
+ # MCP 工具自带 JSON Schema(透传,不套字符串化的 _split_tool_args)
803
+ out.append({"type": "function", "function": {
804
+ "name": m["full_name"],
805
+ "description": (m["description"] + "(MCP 工具,来自服务器 %s)" % m["server"]).strip(),
806
+ "parameters": m["input_schema"]}})
807
+ return out
808
+
809
+
810
+ def _anthropic_tools(extra_specs=None):
811
+ out = []
812
+ for t in list(TOOLS_SPEC) + list(extra_specs or []):
813
+ props, required = _split_tool_args(t)
814
+ out.append({"name": t["name"], "description": t["description"],
815
+ "input_schema": {"type": "object", "properties": props,
816
+ "required": required}})
817
+ for m in _mcp_specs():
818
+ out.append({"name": m["full_name"],
819
+ "description": (m["description"] + "(MCP 工具,来自服务器 %s)" % m["server"]).strip(),
820
+ "input_schema": m["input_schema"]})
821
+ return out
439
822
 
440
823
 
441
824
  def _post_json(url, headers, body, allow_private, timeout):
@@ -822,7 +1205,7 @@ def _m_anthropic(m):
822
1205
 
823
1206
 
824
1207
  def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream=False,
825
- max_tokens=BASE_MAX_TOKENS):
1208
+ max_tokens=BASE_MAX_TOKENS, extra_specs=None):
826
1209
  """按协议构造 (url, headers, body)。msgs 为内部统一形状。
827
1210
 
828
1211
  stream=True 时按协议打开流式:google 换 :streamGenerateContent?alt=sse,
@@ -857,7 +1240,7 @@ def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream
857
1240
  body = {"model": model, "max_tokens": max_tokens, "system": system,
858
1241
  "messages": [_m_anthropic(m) for m in msgs]}
859
1242
  if with_tools:
860
- body["tools"] = _anthropic_tools()
1243
+ body["tools"] = _anthropic_tools(extra_specs)
861
1244
  if stream:
862
1245
  body["stream"] = True
863
1246
  return url, headers, body
@@ -873,7 +1256,7 @@ def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream
873
1256
  body = {"model": model, "max_tokens": max_tokens,
874
1257
  "messages": [{"role": "system", "content": system}] + msgs_wire}
875
1258
  if with_tools:
876
- body["tools"] = _openai_tools()
1259
+ body["tools"] = _openai_tools(extra_specs)
877
1260
  if stream:
878
1261
  body["stream"] = True
879
1262
  body["stream_options"] = {"include_usage": True}
@@ -943,7 +1326,8 @@ def _norm_usage(usage):
943
1326
 
944
1327
 
945
1328
  def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None,
946
- on_reason=None, on_stream=None, on_activity=None, stream=True, deadline=None):
1329
+ on_reason=None, on_stream=None, on_activity=None, stream=True, deadline=None,
1330
+ task_creator=None):
947
1331
  """跑一次内置智能体(内部自带工具循环直到给出最终回答)。
948
1332
 
949
1333
  bi: resolve() 的返回;prompt: 本轮完整输入(目标/续轮块由 pipeline 拼);
@@ -982,6 +1366,11 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
982
1366
  max_tokens = BASE_MAX_TOKENS # 思考占满预算时提额重试(跨迭代保持,见空正文分支)
983
1367
  escalated = False # 本轮 run 是否已提额(只提一次,防无限翻倍)
984
1368
  empty_streak = 0 # 连续零正文轮数(拿到正文/工具即清零)
1369
+ last_action_was_tools = False
1370
+ repeat_tool_signature = ""
1371
+ repeat_tool_streak = 0
1372
+ repeat_stop = False
1373
+ finalize_attempted = False
985
1374
  perf = {"ttft": [], "tps": []} # 各次流式调用的首字延迟(ms)与吞吐(tok/s)
986
1375
 
987
1376
  def _perf():
@@ -1033,7 +1422,20 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
1033
1422
  out["cancelled"] = True
1034
1423
  return out
1035
1424
 
1036
- for it in range(1, MAX_TOOL_ITERS + 1):
1425
+ # 工具上限后最多再给一次“只输出总结”的机会。这样最后一轮已经
1426
+ # 成功写文件/抓数据但尚未总结时,不会把真实成果误报成失败。
1427
+ for it in range(1, MAX_TOOL_ITERS + 2):
1428
+ force_finalize = it > MAX_TOOL_ITERS or repeat_stop
1429
+ if force_finalize and (finalize_attempted or not last_action_was_tools):
1430
+ break
1431
+ if force_finalize and not finalize_attempted:
1432
+ finalize_attempted = True
1433
+ msgs.append({"role": "user", "content":
1434
+ "请停止调用工具,只根据已经完成的操作直接给出最终答复。"
1435
+ "列出已完成内容、产出文件和仍未完成的事项;不要继续探索。"})
1436
+ if log:
1437
+ log("[收尾] 工具循环已到边界,发起一次无工具最终总结")
1438
+ _fire(on_activity, "工具循环到边界,正在生成最终总结")
1037
1439
  if deadline is not None and time.monotonic() >= deadline:
1038
1440
  return _deadline_fail()
1039
1441
  if cancel_event is not None and cancel_event.is_set():
@@ -1053,15 +1455,18 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
1053
1455
  return _deadline_fail()
1054
1456
  req_timeout = min(float(timeout), max(0.1, deadline - time.monotonic())) \
1055
1457
  if deadline is not None else timeout
1458
+ allow_tools = tools_ok and not force_finalize
1056
1459
  url, headers, body = _build_request(
1057
1460
  proto, pbase, kk["key"], model, system, msgs,
1058
- tools_ok, max_tokens=max_tokens)
1461
+ allow_tools, max_tokens=max_tokens,
1462
+ extra_specs=[CREATE_TASK_SPEC] if task_creator else None)
1059
1463
  sbody = None
1060
1464
  if stream:
1061
1465
  # 流式体单独构造(+stream / include_usage);非流式体留给回落重发
1062
1466
  _, _, sbody = _build_request(
1063
1467
  proto, pbase, kk["key"], model, system, msgs,
1064
- tools_ok, stream=True, max_tokens=max_tokens)
1468
+ allow_tools, stream=True, max_tokens=max_tokens,
1469
+ extra_specs=[CREATE_TASK_SPEC] if task_creator else None)
1065
1470
  effort = str(bi.get("reasoning_effort") or "").strip().lower()
1066
1471
  # reasoning_effort 是 OpenAI wire 字段;Anthropic thinking 使用
1067
1472
  # 另一套对象结构,向兼容网关硬塞该字段会直接得到 400。
@@ -1155,7 +1560,27 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
1155
1560
  break
1156
1561
  if not got:
1157
1562
  continue # 本 KEY 失败,换下一个
1563
+ if calls and force_finalize:
1564
+ # 收尾请求明确禁用工具;模型若仍返回 tool call,不能再
1565
+ # 开启新一轮,否则会绕过止损边界。
1566
+ last_err = "收尾时模型仍请求工具"
1567
+ last_action_was_tools = False
1568
+ done = True
1569
+ break
1158
1570
  if calls:
1571
+ sig = json.dumps(
1572
+ [(c.get("name"), c.get("args") or {}) for c in calls],
1573
+ ensure_ascii=False, sort_keys=True, separators=(",", ":"))
1574
+ if sig == repeat_tool_signature:
1575
+ repeat_tool_streak += 1
1576
+ else:
1577
+ repeat_tool_signature = sig
1578
+ repeat_tool_streak = 1
1579
+ if repeat_tool_streak >= 3:
1580
+ repeat_stop = True
1581
+ if log:
1582
+ log("[迭代 %d] 检测到同一工具调用连续重复,转入收尾" % it)
1583
+ _fire(on_activity, "检测到重复工具调用,正在收尾")
1159
1584
  if log:
1160
1585
  log("[迭代 %d] %s 请求工具: %s" % (
1161
1586
  it, model, ", ".join(c["name"] for c in calls)))
@@ -1163,7 +1588,7 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
1163
1588
  results = []
1164
1589
  for c in calls:
1165
1590
  out = _exec_tool(workdir, c["name"], c["args"], cancel_event,
1166
- deadline=deadline)
1591
+ deadline=deadline, task_creator=task_creator)
1167
1592
  if log:
1168
1593
  brief = out if len(out) <= 120 else out[:120] + "…"
1169
1594
  log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
@@ -1174,6 +1599,7 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
1174
1599
  msgs.append({"role": "assistant", "content": text, "tool_calls": calls})
1175
1600
  msgs.append({"role": "tool_results", "tool_results": results})
1176
1601
  empty_streak = 0
1602
+ last_action_was_tools = True
1177
1603
  done = True
1178
1604
  break
1179
1605
  if (text or "").strip():
@@ -1181,10 +1607,12 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
1181
1607
  log("[迭代 %d] 最终回答(%d 字)" % (it, len(text)))
1182
1608
  ok = True
1183
1609
  empty_streak = 0
1610
+ last_action_was_tools = False
1184
1611
  done = True
1185
1612
  break
1186
1613
  # —— 零正文零工具:分辨「思考占满输出预算」还是「模型真没说话」。
1187
1614
  # 推理模型的思考计入 max_tokens:想满了流会正常收,正文却是空的。
1615
+ last_action_was_tools = False
1188
1616
  rlen = len(reasons[-1] or "")
1189
1617
  out_tok = int(usage.get("output") or 0)
1190
1618
  exhausted = (rlen >= THINK_EXHAUST_MIN_CHARS
@@ -1225,6 +1653,17 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
1225
1653
  break # 所有 wire/KEY 都失败
1226
1654
  if ok or stopit:
1227
1655
  break
1656
+ if not ok and not text and finalize_attempted:
1657
+ # 工具已经执行过且收尾调用也未能给正文:把真实执行结果作为“部分完成”
1658
+ # 返回。pipeline 会按成功步骤落盘,用户可以继续追问而不是被迫重跑。
1659
+ partial = _fail(last_err or "已完成工具操作,但未生成最终总结")
1660
+ partial["ok"] = True
1661
+ partial["partial"] = True
1662
+ partial["text"] = ("部分完成:已执行部分操作,但未能生成最终总结。"
1663
+ "请检查工作目录中的产出文件,或继续追问以完成剩余事项。")
1664
+ partial["error"] = last_err or "已完成工具操作,但未生成最终总结"
1665
+ partial.setdefault("raw", {})["partial"] = True
1666
+ return partial
1228
1667
  if not ok and not text:
1229
1668
  return _fail(last_err or "工具循环达上限仍无最终回答")
1230
1669
  return {"ok": True, "text": (text or "").strip(), "usage": dict(total_usage),