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.
- package/CHANGELOG.md +37 -0
- package/README.md +4 -3
- package/app/browser-final-1440.png +0 -0
- package/app/browser-final-390.png +0 -0
- package/app/browser-google-fallback.png +0 -0
- package/app/browser-google-test.png +0 -0
- package/app/browser-workbench-1440.png +0 -0
- package/app/browser-workbench-390.png +0 -0
- package/app/browser-workspace-google.png +0 -0
- package/app/browser-workspace-smoke.png +0 -0
- package/app/browser-workspace.png +0 -0
- package/app/core/audit.py +30 -0
- package/app/core/automation.py +107 -12
- package/app/core/benchstore.py +95 -0
- package/app/core/bookmeta.py +9 -4
- package/app/core/builtin_agent.py +472 -33
- package/app/core/catalog.py +7 -1
- package/app/core/dispatch.py +20 -3
- package/app/core/error_codes.py +11 -2
- package/app/core/evalbench.py +720 -0
- package/app/core/flows.py +228 -3
- package/app/core/jobs.py +22 -1
- package/app/core/manager.py +21 -0
- package/app/core/market_remote.py +10 -0
- package/app/core/mcp_client.py +287 -0
- package/app/core/modelhub.py +185 -2
- package/app/core/notify.py +118 -23
- package/app/core/novel_quality.py +189 -0
- package/app/core/paths.py +4 -0
- package/app/core/pipeline.py +426 -28
- package/app/core/planner.py +243 -22
- package/app/core/plugins.py +525 -0
- package/app/core/publish/browser.py +129 -13
- package/app/core/publish/fanqie.py +6 -0
- package/app/core/publish/flow.py +154 -5
- package/app/core/publish/flows-fanqie-calibrated.json +11 -7
- package/app/core/publish/ledger.py +29 -3
- package/app/core/publish/manager.py +134 -2
- package/app/core/publish/qimao.py +5 -0
- package/app/core/remotesync.py +121 -0
- package/app/core/runner.py +80 -5
- package/app/core/settings.py +39 -2
- package/app/core/settings_schema.py +8 -0
- package/app/core/store.py +30 -8
- package/app/core/usage.py +187 -14
- package/app/core/zentao.py +153 -4
- package/app/current-browser.png +0 -0
- package/app/main.py +279 -15
- package/app/mcp_server.py +175 -0
- package/app/pet.py +82 -21
- package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
- package/app/plugins/code-review-helper/skills/review.md +16 -0
- package/app/ui/app.js +1380 -73
- package/app/ui/i18n.js +248 -6
- package/app/ui/index.html +277 -13
- package/app/ui/style.css +786 -0
- 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 =
|
|
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
|
-
|
|
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
|
|
395
|
-
"args": {"path": "文件相对路径"
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|