codebee 0.1.96 → 0.1.98
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 +13 -0
- package/README.md +4 -2
- package/app/core/agent_context.py +21 -3
- package/app/core/board.py +39 -2
- package/app/core/builtin_agent.py +409 -34
- package/app/core/checkpoints.py +505 -0
- package/app/core/jobs.py +8 -1
- package/app/core/manager.py +109 -11
- package/app/core/mcp_client.py +200 -29
- package/app/core/pipeline.py +289 -40
- package/app/core/policy.py +36 -4
- package/app/core/project_memory.py +139 -0
- package/app/core/publish/auto.py +80 -19
- package/app/core/publish/browser.py +39 -0
- package/app/core/publish/fanqie.py +2 -0
- package/app/core/publish/flow.py +96 -17
- package/app/core/publish/flows-fanqie-calibrated.json +23 -11
- package/app/core/publish/ledger.py +38 -1
- package/app/core/runner.py +21 -11
- package/app/core/settings.py +13 -5
- package/app/core/settings_schema.py +14 -3
- package/app/core/store.py +21 -6
- package/app/core/tool_outputs.py +107 -0
- package/app/core/wxdigest.py +8 -1
- package/app/main.py +76 -2
- package/app/ui/app.js +356 -82
- package/app/ui/assets/workbench-reference.png +0 -0
- package/app/ui/board.css +451 -666
- package/app/ui/board.html +97 -94
- package/app/ui/board.js +316 -112
- package/app/ui/hive3d.js +242 -0
- package/app/ui/i18n.js +93 -10
- package/app/ui/index.html +31 -3
- package/app/ui/style.css +97 -111
- package/package.json +3 -2
|
@@ -19,7 +19,10 @@ import json
|
|
|
19
19
|
import mimetypes
|
|
20
20
|
import os
|
|
21
21
|
import re
|
|
22
|
+
import secrets
|
|
22
23
|
import shutil
|
|
24
|
+
import stat
|
|
25
|
+
import sys
|
|
23
26
|
import threading
|
|
24
27
|
import time
|
|
25
28
|
import urllib.parse
|
|
@@ -48,6 +51,12 @@ CMD_DEFAULT_TIMEOUT = 600 # run_command 默认超时(与 CLI 停滞看门
|
|
|
48
51
|
CMD_MAX_TIMEOUT = 1800 # 上限:模型传 timeout_sec 超过即钳到这
|
|
49
52
|
CMD_OUT_HEAD = 24 * 1024 # 回灌给模型的输出:头 24K + 尾 8K(报错常在尾部)
|
|
50
53
|
CMD_OUT_TAIL = 8 * 1024
|
|
54
|
+
TOOL_HISTORY_KEEP_ROUNDS = 4
|
|
55
|
+
TOOL_HISTORY_OLD_RESULT_CHARS = 1200
|
|
56
|
+
MAX_DIRECT_CONTEXT_CHARS = 48000 # ~12K tokens; keep direct chat within small model windows
|
|
57
|
+
_TOOL_OUTPUT_MARKER_RE = re.compile(
|
|
58
|
+
r"\n…\[较早结果已卸载到本机;需查看全文请调用 read_tool_output,"
|
|
59
|
+
r"ref=([a-f0-9]{32}) \]…\n")
|
|
51
60
|
|
|
52
61
|
_SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户交代的任务。用户的目标、背景与工作目录内的附件就是全部输入。
|
|
53
62
|
|
|
@@ -56,6 +65,7 @@ _SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户
|
|
|
56
65
|
- 改文件的优先级:小改动用 edit_file(精确查找替换,不必整份重写)>追加用 append_file>整份重建才用 write_file。删除/移动/重命名/复制用 fs_manage,不要绕 shell。
|
|
57
66
|
- 大文件(超 64KB)默认只读头尾、中段省略;要看全用 read_file 的 offset/lines 分段读(返回头会提示下一段的 offset)。找内容用 search_content(带行号的 grep),找文件用 find_files,不要逐个文件读。
|
|
58
67
|
- read_file 只能读文本文件;图片、压缩包等二进制文件读不了,如实告知用户即可,不要反复尝试。
|
|
68
|
+
- 较早工具结果若提示已卸载并给出 ref,使用 read_tool_output 按需读取(可传 offset 分段);不要假设压缩标记就是完整结果。
|
|
59
69
|
- 用户消息中的图片附件会直接出现在对话里,可直接看图作答,无需用工具读取。
|
|
60
70
|
- run_command 直接在用户的电脑上执行命令并回传退出码与输出:诊断、修复、改配置、重启服务等操作会真实生效。命令跑完看输出再决定下一步,不要一次性罗列步骤让用户自己敲。
|
|
61
71
|
- 当工具列表中提供 create_task 时,如果用户明确要求开始另一项 CodeBee 工作(例如“新建连载小说任务”“再开一个代码任务”),调用它发起独立任务;不要只在当前回答里描述应该怎么做。先从用户话里提取任务类型和目标,缺少关键目标时再追问。
|
|
@@ -134,6 +144,20 @@ def resolve(provider_id="", model="", difficulty="default"):
|
|
|
134
144
|
# 模型给的相对路径不可信:逐段拒绝 .. 与盘符,resolve() 归一后确认仍位于
|
|
135
145
|
# 工作目录之下(base not in p.parents 即越界,同 skills/market 的守卫惯用法)。
|
|
136
146
|
|
|
147
|
+
def _is_linklike(path):
|
|
148
|
+
"""Treat symlinks and Windows junctions alike when enumerating a sandbox root."""
|
|
149
|
+
try:
|
|
150
|
+
if path.is_symlink() or (hasattr(path, "is_junction") and path.is_junction()):
|
|
151
|
+
return True
|
|
152
|
+
# Path.is_junction() was added in Python 3.12. On supported older
|
|
153
|
+
# Windows versions, detect all reparse points directly from lstat so
|
|
154
|
+
# junctions cannot bypass a narrowed allowed_roots policy.
|
|
155
|
+
attributes = getattr(path.lstat(), "st_file_attributes", 0)
|
|
156
|
+
reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
157
|
+
return bool(attributes & reparse_point)
|
|
158
|
+
except OSError:
|
|
159
|
+
return True
|
|
160
|
+
|
|
137
161
|
def _tool_list_files(workdir, args):
|
|
138
162
|
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
139
163
|
if ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
@@ -146,9 +170,12 @@ def _tool_list_files(workdir, args):
|
|
|
146
170
|
return "(目录不存在: %s)" % (rel or ".")
|
|
147
171
|
out = []
|
|
148
172
|
for root, dirs, files in os.walk(str(p)):
|
|
149
|
-
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", "node_modules")
|
|
173
|
+
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", "node_modules")
|
|
174
|
+
and not _is_linklike(Path(root) / d)]
|
|
150
175
|
for f in files:
|
|
151
176
|
fp = os.path.join(root, f)
|
|
177
|
+
if _is_linklike(Path(fp)):
|
|
178
|
+
continue
|
|
152
179
|
relp = os.path.relpath(fp, str(base)).replace(os.sep, "/")
|
|
153
180
|
try:
|
|
154
181
|
size = os.path.getsize(fp)
|
|
@@ -388,7 +415,7 @@ def _tool_write_file(workdir, args):
|
|
|
388
415
|
return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
|
|
389
416
|
|
|
390
417
|
|
|
391
|
-
def _tool_run_command(workdir, args, cancel_event=None, deadline=None):
|
|
418
|
+
def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=None):
|
|
392
419
|
"""本机执行一条 shell 命令,返回 (退出码, 合并输出)。
|
|
393
420
|
|
|
394
421
|
薄适配层:执行/杀树/取消/解码全部复用 runner.run_process(shell_cmd 分支
|
|
@@ -399,16 +426,59 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None):
|
|
|
399
426
|
cmdline = str(args.get("command") or "").strip()
|
|
400
427
|
if not cmdline:
|
|
401
428
|
return "(命令为空,未执行)"
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
429
|
+
from . import policy
|
|
430
|
+
sandbox = sandbox or policy.normalize_sandbox({}, workdir)
|
|
431
|
+
timeout = _command_timeout(args, sandbox)
|
|
432
|
+
cwd = os.path.abspath(workdir or ".")
|
|
433
|
+
if not policy.path_allowed(cwd, sandbox):
|
|
434
|
+
return "(沙箱拒绝:工作目录不在允许范围内,命令未执行)"
|
|
435
|
+
# This process runner has no OS-level network/filesystem namespace. Do not
|
|
436
|
+
# imply network isolation when policy requests it; fail closed instead.
|
|
437
|
+
if sandbox.get("network") is False:
|
|
438
|
+
if os.name != "posix" or not shutil.which("bwrap"):
|
|
439
|
+
return "(沙箱拒绝:当前系统没有可用的网络隔离后端,命令未执行)"
|
|
440
|
+
# Enforce the declared filesystem boundary at the process boundary too.
|
|
441
|
+
# bubblewrap is the supported Linux backend. Windows/macOS without an
|
|
442
|
+
# equivalent backend fail closed rather than pretending cwd is a sandbox.
|
|
443
|
+
if os.name == "posix" and shutil.which("bwrap"):
|
|
444
|
+
argv = ["bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/"]
|
|
445
|
+
# Mount only the runtime needed by ordinary commands. Do not expose the
|
|
446
|
+
# entire host root read-only: read access can still leak user secrets.
|
|
447
|
+
for system_path in ("/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"):
|
|
448
|
+
if os.path.exists(system_path):
|
|
449
|
+
argv.extend(["--dir", system_path, "--ro-bind", system_path, system_path])
|
|
450
|
+
for volatile_path in ("/proc", "/dev"):
|
|
451
|
+
argv.extend(["--dir", volatile_path])
|
|
452
|
+
argv.extend(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"])
|
|
453
|
+
if sandbox.get("network") is False:
|
|
454
|
+
argv.append("--unshare-net")
|
|
455
|
+
for allowed_root in sandbox.get("allowed_roots") or []:
|
|
456
|
+
root_path = str(Path(allowed_root).resolve())
|
|
457
|
+
parents = list(reversed(Path(root_path).parents))
|
|
458
|
+
for parent in parents:
|
|
459
|
+
if str(parent) != "/":
|
|
460
|
+
argv.extend(["--dir", str(parent)])
|
|
461
|
+
argv.extend(["--dir", root_path])
|
|
462
|
+
argv.extend(["--bind", root_path, root_path])
|
|
463
|
+
argv.extend(["--chdir", cwd, "--", "/bin/sh", "-lc", cmdline])
|
|
464
|
+
elif os.name == "posix" and sys.platform == "darwin":
|
|
465
|
+
return "(沙箱拒绝:当前 macOS 执行器尚未接入文件系统隔离后端,命令未执行)"
|
|
466
|
+
elif os.name != "posix":
|
|
467
|
+
return "(沙箱拒绝:当前 Windows 执行器尚未接入文件系统隔离后端,命令未执行)"
|
|
468
|
+
else:
|
|
469
|
+
return "(沙箱拒绝:未安装 bubblewrap 隔离后端,命令未执行)"
|
|
470
|
+
env_policy = dict(sandbox)
|
|
471
|
+
env_policy["env_allowlist"] = list(dict.fromkeys(
|
|
472
|
+
list(sandbox.get("env_allowlist") or [])
|
|
473
|
+
+ ["PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "COMSPEC", "HOME", "USERPROFILE"]))
|
|
474
|
+
r = runner.run_process(argv=argv, cwd=cwd,
|
|
475
|
+
env=policy.filter_env(os.environ, env_policy),
|
|
476
|
+
timeout=min(timeout, CMD_MAX_TIMEOUT),
|
|
408
477
|
deadline=deadline, cancel_event=cancel_event)
|
|
409
478
|
out = runner.clean_cli_text(
|
|
410
479
|
(r.get("stdout") or "") + (("\n" + r["stderr"]) if r.get("stderr") else ""))
|
|
411
|
-
limit = CMD_OUT_HEAD + CMD_OUT_TAIL
|
|
480
|
+
limit = min(CMD_OUT_HEAD + CMD_OUT_TAIL,
|
|
481
|
+
max(1024, int(sandbox.get("max_output_bytes") or CMD_OUT_HEAD + CMD_OUT_TAIL)))
|
|
412
482
|
if not out.strip():
|
|
413
483
|
body = "(无输出)"
|
|
414
484
|
elif len(out) <= limit:
|
|
@@ -426,7 +496,21 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None):
|
|
|
426
496
|
note = ""
|
|
427
497
|
prefix = ("(%s)\n" % note) if note else ""
|
|
428
498
|
code = r.get("exit_code")
|
|
429
|
-
return "%s退出码: %s\n%s
|
|
499
|
+
return ("%s退出码: %s\n%s\n(注意:shell 命令副作用不纳入文件检查点,"
|
|
500
|
+
"不能保证可回滚)") % (prefix, "未知" if code is None else code, body)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _command_timeout(args, sandbox):
|
|
504
|
+
"""Resolve the shell timeout without exceeding the task's sandbox budget."""
|
|
505
|
+
try:
|
|
506
|
+
requested = int(float((args or {}).get("timeout_sec") or CMD_DEFAULT_TIMEOUT))
|
|
507
|
+
except (TypeError, ValueError):
|
|
508
|
+
requested = CMD_DEFAULT_TIMEOUT
|
|
509
|
+
try:
|
|
510
|
+
policy_limit = int(float((sandbox or {}).get("timeout_s") or CMD_MAX_TIMEOUT))
|
|
511
|
+
except (TypeError, ValueError):
|
|
512
|
+
policy_limit = CMD_MAX_TIMEOUT
|
|
513
|
+
return max(1, min(requested, policy_limit, CMD_MAX_TIMEOUT))
|
|
430
514
|
|
|
431
515
|
|
|
432
516
|
# ---------------------------------------------------------------- P0 基础件扩展
|
|
@@ -593,7 +677,8 @@ def _tool_search_content(workdir, args):
|
|
|
593
677
|
base = Path(workdir or ".").resolve()
|
|
594
678
|
out, hits, scanned = [], 0, 0
|
|
595
679
|
for root, dirs, files in os.walk(str(sub)):
|
|
596
|
-
dirs[:] = [d for d in dirs if d not in _WALK_PRUNE
|
|
680
|
+
dirs[:] = [d for d in dirs if d not in _WALK_PRUNE
|
|
681
|
+
and not _is_linklike(Path(root) / d)]
|
|
597
682
|
for f in files:
|
|
598
683
|
if glob_pat:
|
|
599
684
|
relp = os.path.relpath(os.path.join(root, f), str(base)).replace(os.sep, "/")
|
|
@@ -601,6 +686,8 @@ def _tool_search_content(workdir, args):
|
|
|
601
686
|
continue
|
|
602
687
|
fp = os.path.join(root, f)
|
|
603
688
|
try:
|
|
689
|
+
if _is_linklike(Path(fp)):
|
|
690
|
+
continue
|
|
604
691
|
if os.path.getsize(fp) > SEARCH_MAX_FILE_BYTES:
|
|
605
692
|
continue
|
|
606
693
|
with open(fp, "rb") as fh:
|
|
@@ -632,15 +719,23 @@ def _tool_find_files(workdir, args):
|
|
|
632
719
|
if not pattern or ".." in pattern or ":" in pattern:
|
|
633
720
|
return "(非法 pattern: %s;示例 *.py 或 data/*.json)" % pattern
|
|
634
721
|
base = Path(workdir or ".").resolve()
|
|
722
|
+
sub, err = _guard_rel(workdir, args.get("path"), allow_root=True)
|
|
723
|
+
if err:
|
|
724
|
+
return err
|
|
725
|
+
if not sub.is_dir():
|
|
726
|
+
return "(目录不存在: %s)" % (args.get("path") or ".")
|
|
635
727
|
match_name = "/" not in pattern
|
|
636
728
|
out = []
|
|
637
|
-
for root, dirs, files in os.walk(str(
|
|
638
|
-
dirs[:] = [d for d in dirs if d not in _WALK_PRUNE
|
|
729
|
+
for root, dirs, files in os.walk(str(sub)):
|
|
730
|
+
dirs[:] = [d for d in dirs if d not in _WALK_PRUNE
|
|
731
|
+
and not _is_linklike(Path(root) / d)]
|
|
639
732
|
for f in files:
|
|
640
733
|
rel = os.path.relpath(os.path.join(root, f), str(base)).replace(os.sep, "/")
|
|
641
734
|
if not (fnmatch.fnmatch(rel, pattern)
|
|
642
735
|
or (match_name and fnmatch.fnmatch(f, pattern))):
|
|
643
736
|
continue
|
|
737
|
+
if _is_linklike(Path(root) / f):
|
|
738
|
+
continue
|
|
644
739
|
try:
|
|
645
740
|
size = os.path.getsize(os.path.join(root, f))
|
|
646
741
|
except OSError:
|
|
@@ -675,7 +770,12 @@ TOOLS_SPEC = [
|
|
|
675
770
|
"?glob": "可选,文件名过滤,如 *.py",
|
|
676
771
|
"?max_results": "可选,命中行数上限(默认 50,最大 200)"}},
|
|
677
772
|
{"name": "find_files", "description": "按文件名通配查找文件(如 *.py、data/*.json、report*.md);pattern 不含 / 时匹配任意深度的文件名",
|
|
678
|
-
"args": {"pattern": "文件名通配模式"
|
|
773
|
+
"args": {"pattern": "文件名通配模式",
|
|
774
|
+
"?path": "可选,限定搜索的子目录,默认根目录"}},
|
|
775
|
+
{"name": "read_tool_output", "description": "按 ref 重新读取本次运行中卸载到本机的较早工具输出;大结果可用 offset 分段读取",
|
|
776
|
+
"args": {"ref": "压缩提示提供的工具结果引用",
|
|
777
|
+
"?offset": "可选,字节偏移,默认 0",
|
|
778
|
+
"?limit": "可选,返回字符数,最大 20000"}},
|
|
679
779
|
{"name": "write_file", "description": "把文本内容写入工作目录内一个文件(UTF-8,整份覆盖,父目录自动创建;只改一小段请用 edit_file)",
|
|
680
780
|
"args": {"path": "文件相对路径", "content": "完整文本内容"}},
|
|
681
781
|
{"name": "run_command",
|
|
@@ -710,7 +810,7 @@ CREATE_TASK_SPEC = {
|
|
|
710
810
|
}
|
|
711
811
|
|
|
712
812
|
|
|
713
|
-
def _tool_create_task(workdir, args, task_creator=None):
|
|
813
|
+
def _tool_create_task(workdir, args, task_creator=None, sandbox=None):
|
|
714
814
|
"""Create a separate task through the caller-owned task boundary."""
|
|
715
815
|
if task_creator is None:
|
|
716
816
|
return "(当前对话不支持发起新任务)"
|
|
@@ -720,13 +820,30 @@ def _tool_create_task(workdir, args, task_creator=None):
|
|
|
720
820
|
return "(type 不能为空;可用类型请使用 serial_novel、novel、code、doc 等)"
|
|
721
821
|
if not goal:
|
|
722
822
|
return "(goal 不能为空)"
|
|
823
|
+
requested_workdir = str(args.get("workdir") or workdir or "").strip()
|
|
824
|
+
if sandbox and requested_workdir:
|
|
825
|
+
from . import policy
|
|
826
|
+
if not policy.path_allowed(requested_workdir, sandbox):
|
|
827
|
+
return "(沙箱拒绝:子任务工作目录超出允许范围,未创建)"
|
|
828
|
+
child_sandbox = dict(sandbox) if isinstance(sandbox, dict) else None
|
|
829
|
+
if child_sandbox and requested_workdir:
|
|
830
|
+
from . import policy
|
|
831
|
+
parent_root = Path(workdir or ".").expanduser().resolve()
|
|
832
|
+
child_root = Path(requested_workdir).expanduser().resolve()
|
|
833
|
+
if child_root != parent_root:
|
|
834
|
+
# The requested child directory is already checked against the
|
|
835
|
+
# parent's roots above. Rebase the inherited allowlist to that
|
|
836
|
+
# directory so task normalization cannot widen it back to a parent.
|
|
837
|
+
child_sandbox["allowed_roots"] = [str(child_root)]
|
|
723
838
|
payload = {
|
|
724
839
|
"type": task_type,
|
|
725
840
|
"goal": goal,
|
|
726
841
|
"title": str(args.get("title") or "").strip(),
|
|
727
|
-
"workdir":
|
|
842
|
+
"workdir": requested_workdir,
|
|
728
843
|
"context": str(args.get("context") or "").strip(),
|
|
729
844
|
}
|
|
845
|
+
if child_sandbox is not None:
|
|
846
|
+
payload["sandbox"] = child_sandbox
|
|
730
847
|
if task_type == "serial_novel":
|
|
731
848
|
serial = {}
|
|
732
849
|
for key in ("chapters", "words_per_chapter", "start_chapter", "variants", "branches"):
|
|
@@ -746,14 +863,67 @@ def _tool_create_task(workdir, args, task_creator=None):
|
|
|
746
863
|
result["task_id"], result.get("run_id") or "", task_type)
|
|
747
864
|
|
|
748
865
|
|
|
749
|
-
def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creator=None
|
|
866
|
+
def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creator=None,
|
|
867
|
+
disabled_tools=None, sandbox=None, checkpoint_run_id="",
|
|
868
|
+
tool_output_run_id=""):
|
|
869
|
+
from . import policy
|
|
870
|
+
denied = set(policy.normalize_disabled_tools(disabled_tools))
|
|
871
|
+
if sandbox is None:
|
|
872
|
+
sandbox = policy.normalize_sandbox({}, workdir)
|
|
873
|
+
denied.update(sandbox.get("disabled_tools") or [])
|
|
874
|
+
if name in denied:
|
|
875
|
+
return "(工具已禁用,拒绝执行: %s)" % str(name or "")[:120]
|
|
876
|
+
# All built-in file tools are constrained to the task's allowed roots, not
|
|
877
|
+
# merely hidden from the model. Shell/MCP effects are handled separately.
|
|
878
|
+
if name in {"list_files", "read_file", "write_file", "edit_file", "append_file",
|
|
879
|
+
"fs_manage", "search_content", "find_files"}:
|
|
880
|
+
keys = ["path", "dest"]
|
|
881
|
+
# Enumeration tools default to the whole workspace. If policy grants
|
|
882
|
+
# only a narrower root, require an explicit in-scope path instead of
|
|
883
|
+
# silently exposing names/content outside that root.
|
|
884
|
+
if name in {"list_files", "search_content", "find_files"} \
|
|
885
|
+
and not (args or {}).get("path") \
|
|
886
|
+
and not policy.path_allowed(workdir or ".", sandbox):
|
|
887
|
+
return "(沙箱拒绝:此工具必须指定允许范围内的 path,未执行)"
|
|
888
|
+
for key in keys:
|
|
889
|
+
value = (args or {}).get(key)
|
|
890
|
+
if not value:
|
|
891
|
+
continue
|
|
892
|
+
candidate = Path(str(value)).expanduser()
|
|
893
|
+
if not candidate.is_absolute():
|
|
894
|
+
candidate = Path(workdir or ".") / candidate
|
|
895
|
+
if not policy.path_allowed(candidate, sandbox):
|
|
896
|
+
return "(沙箱拒绝:%s 超出允许工作目录,未执行)" % key
|
|
750
897
|
if name == "create_task":
|
|
751
|
-
return _tool_create_task(workdir, args or {}, task_creator=task_creator
|
|
898
|
+
return _tool_create_task(workdir, args or {}, task_creator=task_creator,
|
|
899
|
+
sandbox=sandbox)
|
|
900
|
+
if name == "read_tool_output":
|
|
901
|
+
from . import tool_outputs
|
|
902
|
+
result = tool_outputs.read(tool_output_run_id, (args or {}).get("ref"),
|
|
903
|
+
offset=(args or {}).get("offset", 0),
|
|
904
|
+
limit=(args or {}).get("limit", 20000))
|
|
905
|
+
if not result.get("ok"):
|
|
906
|
+
return "(%s)" % result.get("error", "工具结果不可用")
|
|
907
|
+
text = result.get("text") or ""
|
|
908
|
+
if result.get("next_offset") is not None:
|
|
909
|
+
text += "\n…[后续内容可继续 read_tool_output,offset=%s]…" % result["next_offset"]
|
|
910
|
+
return text
|
|
752
911
|
if str(name or "").startswith("mcp__"):
|
|
912
|
+
# MCP process launch enforces this policy through bubblewrap; platforms
|
|
913
|
+
# without the isolation backend fail closed in mcp_client._sandbox_launch.
|
|
914
|
+
remaining = None
|
|
915
|
+
if deadline is not None:
|
|
916
|
+
remaining = float(deadline) - time.monotonic()
|
|
917
|
+
if remaining <= 0:
|
|
918
|
+
return "(任务总时限已到,MCP 工具未执行)"
|
|
753
919
|
# MCP 工具:透传给配置的服务器(stdio JSON-RPC);超时给足但封顶
|
|
754
920
|
try:
|
|
755
921
|
from . import mcp_client
|
|
756
|
-
|
|
922
|
+
timeout_s = min(float(sandbox.get("timeout_s") or CMD_MAX_TIMEOUT),
|
|
923
|
+
remaining if remaining is not None else CMD_MAX_TIMEOUT)
|
|
924
|
+
r = mcp_client.dispatch_full_name(
|
|
925
|
+
name, args or {}, sandbox=sandbox, workdir=workdir,
|
|
926
|
+
disabled_tools=denied, timeout_s=timeout_s)
|
|
757
927
|
return r.get("text") or ("(MCP 工具失败: %s)" % r.get("error") if not r.get("ok") else "")
|
|
758
928
|
except Exception as e:
|
|
759
929
|
return "MCP 工具执行失败: %s" % e
|
|
@@ -761,9 +931,50 @@ def _exec_tool(workdir, name, args, cancel_event=None, deadline=None, task_creat
|
|
|
761
931
|
if fn is None:
|
|
762
932
|
return "(未知工具: %s)" % name
|
|
763
933
|
try:
|
|
934
|
+
snapshot_paths = []
|
|
935
|
+
if checkpoint_run_id and name in {"write_file", "edit_file", "append_file", "fs_manage"}:
|
|
936
|
+
action = str((args or {}).get("action") or "").lower()
|
|
937
|
+
src_value = (args or {}).get("path")
|
|
938
|
+
src = Path(str(src_value)).expanduser() if src_value else None
|
|
939
|
+
if src is not None and not src.is_absolute():
|
|
940
|
+
src = Path(workdir or ".") / src
|
|
941
|
+
if src is not None:
|
|
942
|
+
if name == "fs_manage" and src.is_dir() and not src.is_symlink():
|
|
943
|
+
return "(检查点拒绝:目录级操作尚不支持完整快照,未执行;请明确改为逐文件操作)"
|
|
944
|
+
snapshot_paths.append(src)
|
|
945
|
+
if action in ("move", "rename", "copy") and (args or {}).get("dest"):
|
|
946
|
+
dest = Path(str(args["dest"])).expanduser()
|
|
947
|
+
if not dest.is_absolute():
|
|
948
|
+
dest = Path(workdir or ".") / dest
|
|
949
|
+
snapshot_paths.append(dest)
|
|
950
|
+
# Keep first-touch order while avoiding duplicate writes.
|
|
951
|
+
snapshot_paths = list(dict.fromkeys(snapshot_paths))
|
|
952
|
+
try:
|
|
953
|
+
from . import checkpoints
|
|
954
|
+
for path in snapshot_paths:
|
|
955
|
+
saved = checkpoints.capture_file(checkpoint_run_id, workdir, path)
|
|
956
|
+
if not saved.get("ok"):
|
|
957
|
+
return "(检查点拒绝:无法安全保存 %s 的原始版本(%s),未执行文件操作)" % (
|
|
958
|
+
path.name, saved.get("reason") or "unknown")
|
|
959
|
+
except Exception:
|
|
960
|
+
return "(检查点拒绝:文件原始版本保存失败,未执行文件操作)"
|
|
764
961
|
if name == "run_command":
|
|
765
|
-
|
|
766
|
-
|
|
962
|
+
result = fn(workdir, args or {}, cancel_event=cancel_event,
|
|
963
|
+
deadline=deadline, sandbox=sandbox)
|
|
964
|
+
else:
|
|
965
|
+
result = fn(workdir, args or {})
|
|
966
|
+
if snapshot_paths:
|
|
967
|
+
checkpoint_warning = ""
|
|
968
|
+
try:
|
|
969
|
+
from . import checkpoints
|
|
970
|
+
for path in snapshot_paths:
|
|
971
|
+
if not checkpoints.mark_file_after(checkpoint_run_id, workdir, path):
|
|
972
|
+
checkpoint_warning = "(检查点警告:操作后状态未能记账,此文件可能无法自动恢复)"
|
|
973
|
+
except Exception:
|
|
974
|
+
checkpoint_warning = "(检查点警告:操作后状态未能记账,此文件可能无法自动恢复)"
|
|
975
|
+
if checkpoint_warning:
|
|
976
|
+
result += "\n" + checkpoint_warning
|
|
977
|
+
return result
|
|
767
978
|
except Exception as e:
|
|
768
979
|
return "工具执行失败: %s" % (e)
|
|
769
980
|
|
|
@@ -781,24 +992,29 @@ def _split_tool_args(t):
|
|
|
781
992
|
return props, required
|
|
782
993
|
|
|
783
994
|
|
|
784
|
-
def _mcp_specs():
|
|
995
|
+
def _mcp_specs(sandbox=None, workdir=None):
|
|
785
996
|
"""MCP 工具清单(带缓存);取不到/未配置返回空表——增强不挡主流程。"""
|
|
786
997
|
try:
|
|
787
998
|
from . import mcp_client
|
|
788
|
-
return mcp_client.tool_specs_cached()
|
|
999
|
+
return mcp_client.tool_specs_cached(sandbox=sandbox, workdir=workdir)
|
|
789
1000
|
except Exception:
|
|
790
1001
|
return []
|
|
791
1002
|
|
|
792
1003
|
|
|
793
|
-
def _openai_tools(extra_specs=None):
|
|
1004
|
+
def _openai_tools(extra_specs=None, disabled_tools=None, sandbox=None, workdir=None):
|
|
1005
|
+
disabled = set(disabled_tools or [])
|
|
794
1006
|
out = []
|
|
795
1007
|
for t in list(TOOLS_SPEC) + list(extra_specs or []):
|
|
1008
|
+
if t["name"] in disabled:
|
|
1009
|
+
continue
|
|
796
1010
|
props, required = _split_tool_args(t)
|
|
797
1011
|
out.append({"type": "function", "function": {
|
|
798
1012
|
"name": t["name"], "description": t["description"],
|
|
799
1013
|
"parameters": {"type": "object", "properties": props,
|
|
800
1014
|
"required": required}}})
|
|
801
|
-
for m in _mcp_specs():
|
|
1015
|
+
for m in _mcp_specs(sandbox=sandbox, workdir=workdir):
|
|
1016
|
+
if m["full_name"] in disabled:
|
|
1017
|
+
continue
|
|
802
1018
|
# MCP 工具自带 JSON Schema(透传,不套字符串化的 _split_tool_args)
|
|
803
1019
|
out.append({"type": "function", "function": {
|
|
804
1020
|
"name": m["full_name"],
|
|
@@ -807,14 +1023,19 @@ def _openai_tools(extra_specs=None):
|
|
|
807
1023
|
return out
|
|
808
1024
|
|
|
809
1025
|
|
|
810
|
-
def _anthropic_tools(extra_specs=None):
|
|
1026
|
+
def _anthropic_tools(extra_specs=None, disabled_tools=None, sandbox=None, workdir=None):
|
|
1027
|
+
disabled = set(disabled_tools or [])
|
|
811
1028
|
out = []
|
|
812
1029
|
for t in list(TOOLS_SPEC) + list(extra_specs or []):
|
|
1030
|
+
if t["name"] in disabled:
|
|
1031
|
+
continue
|
|
813
1032
|
props, required = _split_tool_args(t)
|
|
814
1033
|
out.append({"name": t["name"], "description": t["description"],
|
|
815
1034
|
"input_schema": {"type": "object", "properties": props,
|
|
816
1035
|
"required": required}})
|
|
817
|
-
for m in _mcp_specs():
|
|
1036
|
+
for m in _mcp_specs(sandbox=sandbox, workdir=workdir):
|
|
1037
|
+
if m["full_name"] in disabled:
|
|
1038
|
+
continue
|
|
818
1039
|
out.append({"name": m["full_name"],
|
|
819
1040
|
"description": (m["description"] + "(MCP 工具,来自服务器 %s)" % m["server"]).strip(),
|
|
820
1041
|
"input_schema": m["input_schema"]})
|
|
@@ -1204,8 +1425,148 @@ def _m_anthropic(m):
|
|
|
1204
1425
|
return {"role": "user", "content": [{"type": "text", "text": m.get("content") or ""}]}
|
|
1205
1426
|
|
|
1206
1427
|
|
|
1428
|
+
def _compact_old_tool_results(messages, keep_rounds=TOOL_HISTORY_KEEP_ROUNDS,
|
|
1429
|
+
old_result_chars=TOOL_HISTORY_OLD_RESULT_CHARS,
|
|
1430
|
+
run_id=""):
|
|
1431
|
+
"""Bound old tool output while preserving recent call/result pairs."""
|
|
1432
|
+
result_indexes = [i for i, msg in enumerate(messages)
|
|
1433
|
+
if msg.get("role") == "tool_results"]
|
|
1434
|
+
keep_rounds = max(0, int(keep_rounds))
|
|
1435
|
+
compact_indexes = result_indexes[:max(0, len(result_indexes) - keep_rounds)]
|
|
1436
|
+
for idx in compact_indexes:
|
|
1437
|
+
msg = messages[idx]
|
|
1438
|
+
compacted = []
|
|
1439
|
+
for call_id, value in msg.get("tool_results") or []:
|
|
1440
|
+
value = str(value or "")
|
|
1441
|
+
if len(value) > old_result_chars:
|
|
1442
|
+
existing_ref = _existing_tool_output_ref(value, run_id, call_id)
|
|
1443
|
+
if existing_ref:
|
|
1444
|
+
# Idempotent across repeated context passes: never save the
|
|
1445
|
+
# already-truncated marker as a new "full" tool output.
|
|
1446
|
+
compacted.append((call_id, value))
|
|
1447
|
+
continue
|
|
1448
|
+
head = old_result_chars * 2 // 3
|
|
1449
|
+
tail = old_result_chars - head
|
|
1450
|
+
try:
|
|
1451
|
+
from . import tool_outputs
|
|
1452
|
+
ref = tool_outputs.save(run_id, call_id, value) if run_id else ""
|
|
1453
|
+
except Exception:
|
|
1454
|
+
ref = ""
|
|
1455
|
+
if ref:
|
|
1456
|
+
marker = ("\n…[较早结果已卸载到本机;需查看全文请调用 read_tool_output,"
|
|
1457
|
+
"ref=%s ]…\n" % ref)
|
|
1458
|
+
else:
|
|
1459
|
+
marker = "\n…[较早工具结果已压缩;需要时请重新读取对应文件/调用工具]…\n"
|
|
1460
|
+
value = value[:head] + marker + value[-tail:]
|
|
1461
|
+
compacted.append((call_id, value))
|
|
1462
|
+
msg["tool_results"] = compacted
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _existing_tool_output_ref(value, run_id, call_id):
|
|
1466
|
+
"""Return a ref only when a marker previews this call's stored full output.
|
|
1467
|
+
|
|
1468
|
+
Tool output is untrusted. A bare ``ref=<hex>`` substring is not evidence of a
|
|
1469
|
+
CodeBee marker: require the complete framing and verify the opaque reference
|
|
1470
|
+
against this call's stored full output before treating it as already unloaded.
|
|
1471
|
+
"""
|
|
1472
|
+
match = _TOOL_OUTPUT_MARKER_RE.search(str(value or ""))
|
|
1473
|
+
if not match or not str(run_id or "").strip():
|
|
1474
|
+
return ""
|
|
1475
|
+
try:
|
|
1476
|
+
from . import tool_outputs
|
|
1477
|
+
is_match = tool_outputs.matches_compacted_output(
|
|
1478
|
+
run_id, call_id, match.group(1), value[:match.start()], value[match.end():])
|
|
1479
|
+
return match.group(1) if is_match else ""
|
|
1480
|
+
except Exception:
|
|
1481
|
+
return ""
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _direct_context_chars(messages):
|
|
1485
|
+
return sum(len(json.dumps(msg, ensure_ascii=False, default=str)) for msg in messages)
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def _bound_direct_context(messages, *, max_chars=MAX_DIRECT_CONTEXT_CHARS,
|
|
1489
|
+
keep_rounds=TOOL_HISTORY_KEEP_ROUNDS, run_id=""):
|
|
1490
|
+
"""Fold complete old tool rounds when a direct-agent conversation grows large.
|
|
1491
|
+
|
|
1492
|
+
Unlike pipeline steps, direct chat has no session-log compaction surface. Keep the
|
|
1493
|
+
original request and the newest tool interactions, but cap older outputs and replace
|
|
1494
|
+
discarded call/result pairs with a bounded factual note. Full large outputs remain
|
|
1495
|
+
available through read_tool_output references.
|
|
1496
|
+
"""
|
|
1497
|
+
try:
|
|
1498
|
+
max_chars = max(1000, int(max_chars))
|
|
1499
|
+
keep_rounds = max(1, int(keep_rounds))
|
|
1500
|
+
except (TypeError, ValueError):
|
|
1501
|
+
max_chars, keep_rounds = MAX_DIRECT_CONTEXT_CHARS, TOOL_HISTORY_KEEP_ROUNDS
|
|
1502
|
+
rounds = []
|
|
1503
|
+
for index, msg in enumerate(messages[:-1]):
|
|
1504
|
+
if msg.get("role") == "assistant" and msg.get("tool_calls") \
|
|
1505
|
+
and messages[index + 1].get("role") == "tool_results":
|
|
1506
|
+
rounds.append((index, index + 1))
|
|
1507
|
+
if not rounds:
|
|
1508
|
+
return
|
|
1509
|
+
if _direct_context_chars(messages) <= max_chars:
|
|
1510
|
+
return
|
|
1511
|
+
|
|
1512
|
+
# First compact all tool results; recent full output is useful only while the
|
|
1513
|
+
# conversation has room. Oversized output is recoverable by its scoped ref.
|
|
1514
|
+
result_budget = max(160, min(TOOL_HISTORY_OLD_RESULT_CHARS,
|
|
1515
|
+
max_chars // max(1, keep_rounds * 8)))
|
|
1516
|
+
_compact_old_tool_results(messages, keep_rounds=0,
|
|
1517
|
+
old_result_chars=result_budget, run_id=run_id)
|
|
1518
|
+
note_index = 1 if messages and messages[0].get("role") == "user" else 0
|
|
1519
|
+
previous_note = (messages[note_index].get("content") or "") \
|
|
1520
|
+
if note_index < len(messages) \
|
|
1521
|
+
and messages[note_index].get("_direct_compaction_note") else ""
|
|
1522
|
+
folded = ([previous_note.split("\n", 1)[1]]
|
|
1523
|
+
if previous_note.startswith("## 更早的工具轮次摘要\n") else [])
|
|
1524
|
+
while len(rounds) > keep_rounds and _direct_context_chars(messages) > max_chars:
|
|
1525
|
+
start, end = rounds.pop(0)
|
|
1526
|
+
assistant, result_msg = messages[start], messages[end]
|
|
1527
|
+
calls = assistant.get("tool_calls") or []
|
|
1528
|
+
tools = ", ".join(str(call.get("name") or "tool")[:80] for call in calls[:8])
|
|
1529
|
+
detail = str(assistant.get("content") or "").strip().replace("\n", " ")[:240]
|
|
1530
|
+
refs = []
|
|
1531
|
+
for call_id, output in (result_msg.get("tool_results") or [])[:5]:
|
|
1532
|
+
value = str(output or "")
|
|
1533
|
+
existing_ref = _existing_tool_output_ref(value, run_id, call_id)
|
|
1534
|
+
if existing_ref:
|
|
1535
|
+
refs.append(existing_ref)
|
|
1536
|
+
elif run_id:
|
|
1537
|
+
try:
|
|
1538
|
+
from . import tool_outputs
|
|
1539
|
+
ref = tool_outputs.save(run_id, call_id, value)
|
|
1540
|
+
if ref:
|
|
1541
|
+
refs.append(ref)
|
|
1542
|
+
except Exception:
|
|
1543
|
+
pass
|
|
1544
|
+
piece = "- 工具 %s;模型说明:%s%s" % (
|
|
1545
|
+
tools or "unknown", detail or "(无)",
|
|
1546
|
+
(";全文 ref=" + ",".join(refs)) if refs else "")
|
|
1547
|
+
folded.append(piece[:500])
|
|
1548
|
+
del messages[start:end + 1]
|
|
1549
|
+
# Tool payloads are untrusted external data: summarize call intent and refs,
|
|
1550
|
+
# never copy output excerpts outside their prompt-guard fences.
|
|
1551
|
+
note = "## 更早的工具轮次摘要\n" + "\n".join(folded)
|
|
1552
|
+
note_limit = min(8000, max(1400, max_chars // 4))
|
|
1553
|
+
if len(note) > note_limit:
|
|
1554
|
+
note = note[-note_limit:]
|
|
1555
|
+
insert_at = 1 if messages and messages[0].get("role") == "user" else 0
|
|
1556
|
+
if insert_at < len(messages) and messages[insert_at].get("_direct_compaction_note"):
|
|
1557
|
+
messages[insert_at]["content"] = note
|
|
1558
|
+
else:
|
|
1559
|
+
messages.insert(insert_at, {"role": "user", "content": note,
|
|
1560
|
+
"_direct_compaction_note": True})
|
|
1561
|
+
# Earlier deletions shift indices; rebuild complete pairs.
|
|
1562
|
+
rounds = [(i, i + 1) for i, msg in enumerate(messages[:-1])
|
|
1563
|
+
if msg.get("role") == "assistant" and msg.get("tool_calls")
|
|
1564
|
+
and messages[i + 1].get("role") == "tool_results"]
|
|
1565
|
+
|
|
1566
|
+
|
|
1207
1567
|
def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream=False,
|
|
1208
|
-
max_tokens=BASE_MAX_TOKENS, extra_specs=None
|
|
1568
|
+
max_tokens=BASE_MAX_TOKENS, extra_specs=None, disabled_tools=None,
|
|
1569
|
+
sandbox=None, workdir=None):
|
|
1209
1570
|
"""按协议构造 (url, headers, body)。msgs 为内部统一形状。
|
|
1210
1571
|
|
|
1211
1572
|
stream=True 时按协议打开流式:google 换 :streamGenerateContent?alt=sse,
|
|
@@ -1240,7 +1601,8 @@ def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream
|
|
|
1240
1601
|
body = {"model": model, "max_tokens": max_tokens, "system": system,
|
|
1241
1602
|
"messages": [_m_anthropic(m) for m in msgs]}
|
|
1242
1603
|
if with_tools:
|
|
1243
|
-
body["tools"] = _anthropic_tools(extra_specs
|
|
1604
|
+
body["tools"] = _anthropic_tools(extra_specs, disabled_tools,
|
|
1605
|
+
sandbox=sandbox, workdir=workdir)
|
|
1244
1606
|
if stream:
|
|
1245
1607
|
body["stream"] = True
|
|
1246
1608
|
return url, headers, body
|
|
@@ -1256,7 +1618,8 @@ def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream
|
|
|
1256
1618
|
body = {"model": model, "max_tokens": max_tokens,
|
|
1257
1619
|
"messages": [{"role": "system", "content": system}] + msgs_wire}
|
|
1258
1620
|
if with_tools:
|
|
1259
|
-
body["tools"] = _openai_tools(extra_specs
|
|
1621
|
+
body["tools"] = _openai_tools(extra_specs, disabled_tools,
|
|
1622
|
+
sandbox=sandbox, workdir=workdir)
|
|
1260
1623
|
if stream:
|
|
1261
1624
|
body["stream"] = True
|
|
1262
1625
|
body["stream_options"] = {"include_usage": True}
|
|
@@ -1327,7 +1690,7 @@ def _norm_usage(usage):
|
|
|
1327
1690
|
|
|
1328
1691
|
def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None,
|
|
1329
1692
|
on_reason=None, on_stream=None, on_activity=None, stream=True, deadline=None,
|
|
1330
|
-
task_creator=None):
|
|
1693
|
+
task_creator=None, sandbox=None, checkpoint_run_id=""):
|
|
1331
1694
|
"""跑一次内置智能体(内部自带工具循环直到给出最终回答)。
|
|
1332
1695
|
|
|
1333
1696
|
bi: resolve() 的返回;prompt: 本轮完整输入(目标/续轮块由 pipeline 拼);
|
|
@@ -1343,6 +1706,9 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1343
1706
|
自动回落非流式,思考字段仍从响应体兜底抽取(on_reason 补发一次)。"""
|
|
1344
1707
|
prov = bi["prov"]
|
|
1345
1708
|
model = bi["model"]
|
|
1709
|
+
from . import policy
|
|
1710
|
+
sandbox = policy.normalize_sandbox(sandbox, workdir)
|
|
1711
|
+
disabled_tools = sandbox.get("disabled_tools") or []
|
|
1346
1712
|
allow_private = bool(prov.get("allow_private"))
|
|
1347
1713
|
system = _SYSTEM_PROMPT + "\n\n## 工作目录\n%s" % os.path.abspath(workdir or ".")
|
|
1348
1714
|
imgs = _prep_images(images, log) if images else []
|
|
@@ -1358,6 +1724,7 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1358
1724
|
candidates = list(modelhub._protocol_candidates(prov))
|
|
1359
1725
|
tools_ok = all(proto != "google" for proto, _ in candidates) # google wire 无工具协议
|
|
1360
1726
|
total_usage = {"input": 0, "cached": 0, "output": 0, "total": 0}
|
|
1727
|
+
tool_output_run_id = checkpoint_run_id or secrets.token_hex(16)
|
|
1361
1728
|
text = ""
|
|
1362
1729
|
iters = 0
|
|
1363
1730
|
last_err = ""
|
|
@@ -1459,14 +1826,18 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1459
1826
|
url, headers, body = _build_request(
|
|
1460
1827
|
proto, pbase, kk["key"], model, system, msgs,
|
|
1461
1828
|
allow_tools, max_tokens=max_tokens,
|
|
1462
|
-
extra_specs=[CREATE_TASK_SPEC] if task_creator else None
|
|
1829
|
+
extra_specs=[CREATE_TASK_SPEC] if task_creator else None,
|
|
1830
|
+
disabled_tools=disabled_tools, sandbox=sandbox,
|
|
1831
|
+
workdir=workdir)
|
|
1463
1832
|
sbody = None
|
|
1464
1833
|
if stream:
|
|
1465
1834
|
# 流式体单独构造(+stream / include_usage);非流式体留给回落重发
|
|
1466
1835
|
_, _, sbody = _build_request(
|
|
1467
1836
|
proto, pbase, kk["key"], model, system, msgs,
|
|
1468
1837
|
allow_tools, stream=True, max_tokens=max_tokens,
|
|
1469
|
-
extra_specs=[CREATE_TASK_SPEC] if task_creator else None
|
|
1838
|
+
extra_specs=[CREATE_TASK_SPEC] if task_creator else None,
|
|
1839
|
+
disabled_tools=disabled_tools, sandbox=sandbox,
|
|
1840
|
+
workdir=workdir)
|
|
1470
1841
|
effort = str(bi.get("reasoning_effort") or "").strip().lower()
|
|
1471
1842
|
# reasoning_effort 是 OpenAI wire 字段;Anthropic thinking 使用
|
|
1472
1843
|
# 另一套对象结构,向兼容网关硬塞该字段会直接得到 400。
|
|
@@ -1588,7 +1959,10 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1588
1959
|
results = []
|
|
1589
1960
|
for c in calls:
|
|
1590
1961
|
out = _exec_tool(workdir, c["name"], c["args"], cancel_event,
|
|
1591
|
-
deadline=deadline, task_creator=task_creator
|
|
1962
|
+
deadline=deadline, task_creator=task_creator,
|
|
1963
|
+
disabled_tools=disabled_tools, sandbox=sandbox,
|
|
1964
|
+
checkpoint_run_id=checkpoint_run_id,
|
|
1965
|
+
tool_output_run_id=tool_output_run_id)
|
|
1592
1966
|
# 运行期注入警示层:外部内容(网页/命令输出/文件)一律
|
|
1593
1967
|
# 包围栏进上下文;检测命中升级强围栏(prompt_guard)。
|
|
1594
1968
|
try:
|
|
@@ -1605,6 +1979,7 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1605
1979
|
results.append((c["id"], out))
|
|
1606
1980
|
msgs.append({"role": "assistant", "content": text, "tool_calls": calls})
|
|
1607
1981
|
msgs.append({"role": "tool_results", "tool_results": results})
|
|
1982
|
+
_bound_direct_context(msgs, run_id=tool_output_run_id)
|
|
1608
1983
|
empty_streak = 0
|
|
1609
1984
|
last_action_was_tools = True
|
|
1610
1985
|
done = True
|