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.
@@ -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
- try:
403
- timeout = int(float(args.get("timeout_sec") or CMD_DEFAULT_TIMEOUT))
404
- except (TypeError, ValueError):
405
- timeout = CMD_DEFAULT_TIMEOUT
406
- r = runner.run_process(shell_cmd=cmdline, cwd=os.path.abspath(workdir or "."),
407
- timeout=max(5, min(timeout, CMD_MAX_TIMEOUT)),
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" % (prefix, "未知" if code is None else code, body)
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(base)):
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": str(args.get("workdir") or workdir or "").strip(),
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
- r = mcp_client.dispatch_full_name(name, args or {})
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
- return fn(workdir, args or {}, cancel_event=cancel_event, deadline=deadline)
766
- return fn(workdir, args or {})
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