docker_runner.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936
  1. """
  2. Docker 内执行工具(模块 ``agent.tools.docker_runner``;与 ``agent.core.runner.AgentRunner`` 无关)。
  3. 解析顺序:**ContextVar(Runner 注入)** → **Trace.context['gateway_exec']** → **环境变量默认容器**
  4. (``AGENT_DEFAULT_DOCKER_CONTAINER``,可选 ``AGENT_DEFAULT_DOCKER_WORKDIR`` /
  5. ``AGENT_DEFAULT_DOCKER_USER``)。有有效 ``docker_container`` 时,``bash_command``、
  6. ``read_file`` / ``write_file`` / ``edit_file`` / ``glob_files`` / ``grep_content`` 走容器内
  7. ``docker exec``;否则仍走原有 builtin(本机)。
  8. - ``GatewayExecResolver`` / ``active_gateway_exec``:``AgentRunner`` 在 ``tools.execute`` 前后 set/reset ContextVar。
  9. - ``BashGatewayDispatcher`` / ``WorkspaceFileToolsDispatcher``:在 ``import agent.tools``(builtin 注册之后)时向 ``ToolRegistry`` 注册包装函数。
  10. 需要 API 进程能访问 Docker(例如挂载 ``/var/run/docker.sock``)。
  11. """
  12. from __future__ import annotations
  13. import asyncio
  14. import base64
  15. import io
  16. import json
  17. import logging
  18. import mimetypes
  19. import os
  20. import posixpath
  21. import tarfile
  22. from contextvars import ContextVar
  23. from pathlib import Path
  24. from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Dict, List, Optional, Tuple
  25. from urllib.parse import urlparse
  26. from agent.tools.builtin.file.edit import replace as edit_replace
  27. from agent.tools.builtin.file.grep import LIMIT as GREP_LIMIT
  28. from agent.tools.builtin.file.read import DEFAULT_READ_LIMIT, MAX_BYTES, MAX_LINE_LENGTH
  29. from agent.tools.builtin.file.write import _create_diff
  30. from agent.tools.builtin.glob_tool import LIMIT as GLOB_LIMIT
  31. from agent.tools.models import ToolContext, ToolResult
  32. if TYPE_CHECKING:
  33. from agent.tools.registry import ToolRegistry
  34. logger = logging.getLogger(__name__)
  35. # ---------------------------------------------------------------------------
  36. # Trace.gateway_exec:ContextVar + 路径解析
  37. # ---------------------------------------------------------------------------
  38. class GatewayExecResolver:
  39. """从工具 context / ContextVar 解析 ``gateway_exec``,并把用户路径映射到容器内路径。"""
  40. ACTIVE: ClassVar[ContextVar[Optional[dict[str, Any]]]] = ContextVar(
  41. "active_gateway_exec", default=None
  42. )
  43. @classmethod
  44. def from_tool_context(cls, context: Any) -> dict[str, Any] | None:
  45. if not isinstance(context, dict):
  46. return None
  47. tc = context.get("trace_context")
  48. if not isinstance(tc, dict):
  49. return None
  50. ge = tc.get("gateway_exec")
  51. return ge if isinstance(ge, dict) else None
  52. @classmethod
  53. def default_gateway_exec_from_env(cls) -> dict[str, Any] | None:
  54. """无 Trace.gateway_exec 时,用环境变量指定默认 Workspace 容器(直连 API / 本地调试)。"""
  55. container = os.getenv("AGENT_DEFAULT_DOCKER_CONTAINER", "").strip()
  56. if not container:
  57. return None
  58. out: dict[str, Any] = {"docker_container": container}
  59. wd = os.getenv("AGENT_DEFAULT_DOCKER_WORKDIR", "").strip()
  60. if wd:
  61. out["container_workdir"] = wd
  62. user = os.getenv("AGENT_DEFAULT_DOCKER_USER", "").strip()
  63. if user:
  64. out["container_user"] = user
  65. return out
  66. @classmethod
  67. def for_trace_context(cls, trace_context: dict[str, Any] | None) -> dict[str, Any] | None:
  68. """Trace.context 中的 gateway_exec 优先,否则环境变量默认容器。"""
  69. tc = trace_context or {}
  70. ge = tc.get("gateway_exec")
  71. if isinstance(ge, dict) and str(ge.get("docker_container") or "").strip():
  72. return ge
  73. return cls.default_gateway_exec_from_env()
  74. @classmethod
  75. def effective(cls, context: Any) -> dict[str, Any] | None:
  76. ge = cls.ACTIVE.get()
  77. if isinstance(ge, dict) and str(ge.get("docker_container") or "").strip():
  78. return ge
  79. if isinstance(context, dict):
  80. tc = context.get("trace_context")
  81. if isinstance(tc, dict):
  82. return cls.for_trace_context(tc)
  83. return cls.default_gateway_exec_from_env()
  84. @staticmethod
  85. def workdir(ge: dict[str, Any]) -> str:
  86. w = str(ge.get("container_workdir") or "/home/agent/workspace").strip()
  87. return w.rstrip("/") or "/home/agent/workspace"
  88. @staticmethod
  89. def user(ge: dict[str, Any]) -> str:
  90. u = str(ge.get("container_user") or "agent").strip()
  91. return u or "agent"
  92. @staticmethod
  93. def _host_mapping_root() -> str | None:
  94. raw = os.getenv("AGENT_WORKSPACE_HOST_PROJECT_ROOT", "").strip()
  95. if raw:
  96. return str(Path(raw).resolve())
  97. try:
  98. return str(Path.cwd().resolve())
  99. except Exception:
  100. return None
  101. @classmethod
  102. def resolve_path(cls, ge: dict[str, Any], user_path: str | None, *, is_dir: bool) -> str | None:
  103. wd = cls.workdir(ge)
  104. if not user_path or not str(user_path).strip():
  105. return wd if is_dir else None
  106. raw = str(user_path).strip().replace("\\", "/")
  107. host_root = cls._host_mapping_root()
  108. if posixpath.isabs(raw):
  109. norm = posixpath.normpath(raw)
  110. if norm == wd or norm.startswith(wd + "/"):
  111. return norm
  112. if host_root:
  113. hr = host_root.replace("\\", "/").rstrip("/")
  114. if norm == hr or norm.startswith(hr + "/"):
  115. rel = posixpath.relpath(norm, hr)
  116. if rel.startswith("../"):
  117. return None
  118. candidate = posixpath.normpath(posixpath.join(wd, rel))
  119. if candidate == wd or candidate.startswith(wd + "/"):
  120. return candidate
  121. return None
  122. return None
  123. for seg in raw.split("/"):
  124. if seg == "..":
  125. return None
  126. candidate = posixpath.normpath(posixpath.join(wd, raw))
  127. if candidate == wd or candidate.startswith(wd + "/"):
  128. return candidate
  129. return None
  130. # 兼容旧导入:runner 使用 ``active_gateway_exec.set`` / ``reset``
  131. active_gateway_exec = GatewayExecResolver.ACTIVE
  132. gateway_exec_from_tool_context = GatewayExecResolver.from_tool_context
  133. effective_gateway_exec = GatewayExecResolver.effective
  134. container_workdir = GatewayExecResolver.workdir
  135. container_user = GatewayExecResolver.user
  136. resolve_container_path = GatewayExecResolver.resolve_path
  137. # ---------------------------------------------------------------------------
  138. # 单会话:Docker 容器内 exec / 读写 / 工具级 read/write/glob/grep/bash
  139. # ---------------------------------------------------------------------------
  140. class DockerWorkspaceClient:
  141. """绑定一份 ``gateway_exec`` 字典,封装对该 Workspace 容器的所有 I/O。"""
  142. __slots__ = ("_ge",)
  143. _BINARY_EXTS = frozenset({
  144. ".zip", ".tar", ".gz", ".exe", ".dll", ".so", ".class",
  145. ".jar", ".war", ".7z", ".doc", ".docx", ".xls", ".xlsx",
  146. ".ppt", ".pptx", ".odt", ".ods", ".odp", ".bin", ".dat",
  147. ".obj", ".o", ".a", ".lib", ".wasm", ".pyc", ".pyo",
  148. })
  149. def __init__(self, ge: dict[str, Any]) -> None:
  150. self._ge = ge
  151. @property
  152. def ge(self) -> dict[str, Any]:
  153. return self._ge
  154. def container_id(self) -> str | None:
  155. c = self._ge.get("docker_container")
  156. if c is None:
  157. return None
  158. s = str(c).strip()
  159. return s or None
  160. def _docker_container(self):
  161. import docker
  162. cid = self.container_id()
  163. if not cid:
  164. raise ValueError("gateway_exec 缺少 docker_container")
  165. return docker.from_env().containers.get(cid)
  166. def sync_exec_argv(
  167. self,
  168. argv: List[str],
  169. *,
  170. workdir: str,
  171. environment: Optional[Dict[str, str]] = None,
  172. ) -> Tuple[int, bytes, bytes]:
  173. ct = self._docker_container()
  174. user = GatewayExecResolver.user(self._ge)
  175. exit_code, output = ct.exec_run(
  176. argv,
  177. user=user,
  178. workdir=workdir,
  179. environment=environment,
  180. demux=True,
  181. )
  182. if isinstance(output, tuple) and len(output) == 2:
  183. stdout_b, stderr_b = output
  184. else:
  185. stdout_b = output if isinstance(output, (bytes, bytearray)) else b""
  186. stderr_b = b""
  187. if stdout_b is None:
  188. stdout_b = b""
  189. if stderr_b is None:
  190. stderr_b = b""
  191. code = int(exit_code) if exit_code is not None else -1
  192. return code, bytes(stdout_b), bytes(stderr_b)
  193. async def async_exec_argv(
  194. self,
  195. argv: List[str],
  196. *,
  197. workdir: str,
  198. environment: Optional[Dict[str, str]] = None,
  199. ) -> Tuple[int, bytes, bytes]:
  200. loop = asyncio.get_running_loop()
  201. return await loop.run_in_executor(
  202. None,
  203. lambda: self.sync_exec_argv(argv, workdir=workdir, environment=environment),
  204. )
  205. def sync_read_file_bytes(self, container_path: str) -> bytes:
  206. ct = self._docker_container()
  207. try:
  208. _stat, stream = ct.get_archive(container_path)
  209. except Exception as e:
  210. logger.debug("get_archive failed path=%s: %s", container_path, e)
  211. raise FileNotFoundError(container_path) from e
  212. chunks = b"".join(stream)
  213. bio = io.BytesIO(chunks)
  214. with tarfile.open(fileobj=bio, mode="r") as tar:
  215. member = tar.next()
  216. if member is None:
  217. return b""
  218. if member.isdir():
  219. raise IsADirectoryError(container_path)
  220. ef = tar.extractfile(member)
  221. if ef is None:
  222. return b""
  223. return ef.read()
  224. @staticmethod
  225. def _posixpath_dir(p: str) -> str:
  226. return os.path.dirname(p.replace("\\", "/"))
  227. @staticmethod
  228. def _posixpath_basename(p: str) -> str:
  229. return os.path.basename(p.replace("\\", "/"))
  230. def sync_write_file_bytes(self, container_path: str, data: bytes) -> None:
  231. ct = self._docker_container()
  232. parent = self._posixpath_dir(container_path) or "/"
  233. base = self._posixpath_basename(container_path)
  234. if not base:
  235. raise ValueError("invalid container_path")
  236. code, _out, err = self.sync_exec_argv(
  237. ["mkdir", "-p", parent],
  238. workdir="/",
  239. )
  240. if code != 0:
  241. raise RuntimeError(
  242. f"mkdir -p failed: {parent!r} code={code} stderr={err.decode('utf-8', errors='replace')}"
  243. )
  244. tar_stream = io.BytesIO()
  245. with tarfile.open(fileobj=tar_stream, mode="w") as tar:
  246. ti = tarfile.TarInfo(name=base)
  247. ti.size = len(data)
  248. ti.mode = 0o644
  249. tar.addfile(ti, io.BytesIO(data))
  250. tar_stream.seek(0)
  251. ok = ct.put_archive(parent, tar_stream)
  252. if not ok:
  253. raise RuntimeError(f"put_archive failed: {container_path!r}")
  254. async def async_read_file_bytes(self, container_path: str) -> bytes:
  255. loop = asyncio.get_running_loop()
  256. return await loop.run_in_executor(None, lambda: self.sync_read_file_bytes(container_path))
  257. async def async_write_file_bytes(self, container_path: str, data: bytes) -> None:
  258. loop = asyncio.get_running_loop()
  259. await loop.run_in_executor(None, lambda: self.sync_write_file_bytes(container_path, data))
  260. def sync_path_exists(self, container_path: str, *, is_dir: bool) -> bool:
  261. flag = "d" if is_dir else "f"
  262. code, _, _ = self.sync_exec_argv(
  263. ["test", "-" + flag, container_path],
  264. workdir="/",
  265. )
  266. return code == 0
  267. @classmethod
  268. def _is_binary_buffer(cls, data: bytes, suffix: str) -> bool:
  269. if suffix.lower() in cls._BINARY_EXTS:
  270. return True
  271. if not data:
  272. return False
  273. buf = data[:4096]
  274. if b"\x00" in buf:
  275. return True
  276. non_printable = sum(1 for b in buf if b < 9 or (13 < b < 32))
  277. return (non_printable / len(buf)) > 0.3 if buf else False
  278. MAX_BASH_OUT: ClassVar[int] = 50_000
  279. async def run_bash_tool(
  280. self,
  281. command: str,
  282. *,
  283. timeout: Optional[int],
  284. workdir: Optional[str],
  285. env: Optional[Dict[str, str]],
  286. description: str,
  287. ) -> ToolResult:
  288. _ = description
  289. timeout_sec = timeout if timeout is not None and timeout > 0 else 120
  290. container = self.container_id()
  291. if not container:
  292. return ToolResult(title="配置错误", output="gateway_exec 缺少 docker_container", error="missing_container")
  293. default_wd = GatewayExecResolver.workdir(self._ge)
  294. inner_wd = str(workdir).strip() if workdir else default_wd
  295. loop = asyncio.get_running_loop()
  296. try:
  297. code, stdout, stderr = await asyncio.wait_for(
  298. loop.run_in_executor(
  299. None,
  300. lambda: self.sync_exec_argv(
  301. ["bash", "-lc", command],
  302. workdir=inner_wd,
  303. environment=env,
  304. ),
  305. ),
  306. timeout=timeout_sec,
  307. )
  308. except asyncio.TimeoutError:
  309. return ToolResult(
  310. title="命令超时",
  311. output=f"docker exec 超时(>{timeout_sec}s): {command[:100]}",
  312. error="Timeout",
  313. metadata={"command": command, "timeout": timeout_sec},
  314. )
  315. except Exception as e:
  316. logger.exception("docker exec 失败 container=%s", container)
  317. return ToolResult(
  318. title="Docker 执行失败",
  319. output=(
  320. f"{e}\n\n请确认 API 容器已挂载 /var/run/docker.sock,"
  321. "且已安装 docker Python 包;容器名在 gateway_exec.docker_container。"
  322. ),
  323. error="docker_error",
  324. )
  325. stdout_text = stdout.decode("utf-8", errors="replace") if stdout else ""
  326. stderr_text = stderr.decode("utf-8", errors="replace") if stderr else ""
  327. truncated = False
  328. if len(stdout_text) > self.MAX_BASH_OUT:
  329. stdout_text = stdout_text[: self.MAX_BASH_OUT] + f"\n\n(输出被截断,总长度: {len(stdout_text)} 字符)"
  330. truncated = True
  331. parts: list[str] = []
  332. if stdout_text:
  333. parts.append(stdout_text)
  334. if stderr_text:
  335. parts.append("\n\n--- stderr ---\n" + stderr_text)
  336. output = "\n".join(parts) if parts else "(命令无输出)"
  337. ok = code == 0
  338. meta: dict[str, Any] = {"exit_code": code, "docker_container": container, "truncated": truncated}
  339. return ToolResult(
  340. title=f"docker bash (exit {code})",
  341. output=output,
  342. error=None if ok else f"exit code {code}",
  343. metadata=meta,
  344. )
  345. async def tool_read_file(self, file_path: str, offset: int, limit: int) -> ToolResult:
  346. cpath = GatewayExecResolver.resolve_path(self._ge, file_path, is_dir=False)
  347. if not cpath:
  348. return ToolResult(
  349. title="路径无效",
  350. output="在 Workspace 模式下路径须相对于工作区根,或为工作区/映射项目根下的绝对路径。",
  351. error="invalid_path",
  352. )
  353. name = Path(cpath.replace("\\", "/")).name
  354. try:
  355. raw = await self.async_read_file_bytes(cpath)
  356. except FileNotFoundError:
  357. return ToolResult(
  358. title="文件未找到",
  359. output=f"文件不存在: {file_path}",
  360. error="File not found",
  361. )
  362. except IsADirectoryError:
  363. return ToolResult(
  364. title="路径错误",
  365. output=f"路径是目录: {file_path}",
  366. error="Is a directory",
  367. )
  368. except Exception as e:
  369. logger.exception("workspace read_file")
  370. return ToolResult(title="读取失败", output=str(e), error=str(e))
  371. mime_type, _ = mimetypes.guess_type(name)
  372. mime_type = mime_type or ""
  373. if mime_type.startswith("image/") and mime_type not in ("image/svg+xml", "image/vnd.fastbidsheet"):
  374. b64_data = base64.b64encode(raw).decode("ascii")
  375. return ToolResult(
  376. title=name,
  377. output=f"图片文件: {name} (MIME: {mime_type}, {len(raw)} bytes)",
  378. metadata={"mime_type": mime_type, "truncated": False, "workspace_container": True},
  379. images=[{"type": "base64", "media_type": mime_type, "data": b64_data}],
  380. )
  381. if mime_type == "application/pdf":
  382. return ToolResult(
  383. title=name,
  384. output=f"PDF 文件: {name}",
  385. metadata={"mime_type": mime_type, "truncated": False, "workspace_container": True},
  386. )
  387. if self._is_binary_buffer(raw, Path(name).suffix):
  388. return ToolResult(
  389. title="二进制文件",
  390. output=f"无法读取二进制文件: {name}",
  391. error="Binary file",
  392. )
  393. try:
  394. text = raw.decode("utf-8")
  395. except UnicodeDecodeError:
  396. return ToolResult(
  397. title="编码错误",
  398. output=f"无法解码文件(非 UTF-8): {name}",
  399. error="Encoding error",
  400. )
  401. lines_no_keep = text.splitlines()
  402. total_lines = len(lines_no_keep)
  403. end_line = min(offset + limit, total_lines)
  404. output_lines: list[str] = []
  405. total_bytes = 0
  406. truncated_by_bytes = False
  407. for i in range(offset, end_line):
  408. line = lines_no_keep[i]
  409. if len(line) > MAX_LINE_LENGTH:
  410. line = line[:MAX_LINE_LENGTH] + "..."
  411. line_bytes = len(line.encode("utf-8")) + (1 if output_lines else 0)
  412. if total_bytes + line_bytes > MAX_BYTES:
  413. truncated_by_bytes = True
  414. break
  415. output_lines.append(line)
  416. total_bytes += line_bytes
  417. formatted = [f"{offset + idx + 1:5d}| {ln}" for idx, ln in enumerate(output_lines)]
  418. output = "<file>\n" + "\n".join(formatted)
  419. last_read_line = offset + len(output_lines)
  420. has_more = total_lines > last_read_line
  421. truncated = has_more or truncated_by_bytes
  422. if truncated_by_bytes:
  423. output += f"\n\n(输出在 {MAX_BYTES} 字节处被截断。使用 offset 读取第 {last_read_line} 行之后)"
  424. elif has_more:
  425. output += f"\n\n(还有更多内容。使用 offset 读取第 {last_read_line} 行之后)"
  426. else:
  427. output += f"\n\n(文件结束 - 共 {total_lines} 行)"
  428. output += "\n</file>"
  429. preview = "\n".join(output_lines[:20])
  430. return ToolResult(
  431. title=name,
  432. output=output,
  433. metadata={
  434. "preview": preview,
  435. "truncated": truncated,
  436. "total_lines": total_lines,
  437. "read_lines": len(output_lines),
  438. "workspace_container": True,
  439. },
  440. )
  441. async def tool_write_file(self, file_path: str, content: str, append: bool) -> ToolResult:
  442. cpath = GatewayExecResolver.resolve_path(self._ge, file_path, is_dir=False)
  443. if not cpath:
  444. return ToolResult(title="路径无效", output="路径不在工作区内。", error="invalid_path")
  445. name = Path(cpath.replace("\\", "/")).name
  446. if self.sync_path_exists(cpath, is_dir=True):
  447. return ToolResult(title="路径错误", output=f"路径是目录: {file_path}", error="Path is a directory")
  448. existed = self.sync_path_exists(cpath, is_dir=False)
  449. old_content = ""
  450. if existed:
  451. try:
  452. old_content = (await self.async_read_file_bytes(cpath)).decode("utf-8", errors="replace")
  453. except Exception:
  454. old_content = ""
  455. if append and existed:
  456. new_content = old_content + content
  457. else:
  458. new_content = content
  459. if existed and old_content:
  460. diff = _create_diff(str(file_path), old_content, new_content)
  461. else:
  462. diff = f"(新建文件: {name})"
  463. try:
  464. await self.async_write_file_bytes(cpath, new_content.encode("utf-8"))
  465. except Exception as e:
  466. logger.exception("workspace write_file")
  467. return ToolResult(title="写入失败", output=str(e), error=str(e))
  468. lines = new_content.count("\n")
  469. if append and existed:
  470. operation = "追加内容到"
  471. elif existed:
  472. operation = "覆盖"
  473. else:
  474. operation = "创建"
  475. return ToolResult(
  476. title=name,
  477. output=f"文件写入成功 ({operation})\n\n{diff}",
  478. metadata={"existed": existed, "append": append, "lines": lines, "diff": diff, "workspace_container": True},
  479. long_term_memory=f"{operation}文件 {name}",
  480. )
  481. async def tool_edit_file(
  482. self,
  483. file_path: str,
  484. old_string: str,
  485. new_string: str,
  486. replace_all: bool,
  487. ) -> ToolResult:
  488. cpath = GatewayExecResolver.resolve_path(self._ge, file_path, is_dir=False)
  489. if not cpath:
  490. return ToolResult(title="路径无效", output="路径不在工作区内。", error="invalid_path")
  491. name = Path(cpath.replace("\\", "/")).name
  492. if not self.sync_path_exists(cpath, is_dir=False):
  493. return ToolResult(title="文件未找到", output=f"文件不存在: {file_path}", error="File not found")
  494. if self.sync_path_exists(cpath, is_dir=True):
  495. return ToolResult(title="路径错误", output=f"路径是目录: {file_path}", error="Path is a directory")
  496. try:
  497. content_old = (await self.async_read_file_bytes(cpath)).decode("utf-8")
  498. except Exception as e:
  499. return ToolResult(title="读取失败", output=str(e), error=str(e))
  500. try:
  501. content_new = edit_replace(content_old, old_string, new_string, replace_all)
  502. except ValueError as e:
  503. return ToolResult(title="替换失败", output=str(e), error=str(e))
  504. diff = _create_diff(file_path, content_old, content_new)
  505. try:
  506. await self.async_write_file_bytes(cpath, content_new.encode("utf-8"))
  507. except Exception as e:
  508. return ToolResult(title="写入失败", output=str(e), error=str(e))
  509. return ToolResult(
  510. title=name,
  511. output=f"编辑成功\n\n{diff}",
  512. metadata={
  513. "replace_all": replace_all,
  514. "workspace_container": True,
  515. "old_lines": content_old.count("\n"),
  516. "new_lines": content_new.count("\n"),
  517. },
  518. long_term_memory=f"编辑文件 {name}",
  519. )
  520. async def tool_glob(self, pattern: str, path: Optional[str]) -> ToolResult:
  521. wd = GatewayExecResolver.workdir(self._ge)
  522. sp = GatewayExecResolver.resolve_path(self._ge, path, is_dir=True) if path else wd
  523. if not sp:
  524. return ToolResult(title="路径无效", output="搜索目录无效。", error="invalid_path")
  525. if not self.sync_path_exists(sp, is_dir=True):
  526. return ToolResult(title="目录不存在", output=f"搜索目录不存在: {path}", error="Directory not found")
  527. cfg = json.dumps({"pattern": pattern, "root": sp, "fetch": GLOB_LIMIT + 1}, ensure_ascii=False)
  528. script = (
  529. "import glob,json,os;"
  530. "from pathlib import Path;"
  531. "c=json.loads(__import__('os').environ['GW_GLOB_CFG']);"
  532. "os.chdir(c['root']);pat=c['pattern'];n=int(c['fetch']);"
  533. "paths=[str(p) for p in Path('.').glob(pat) if p.is_file()] if '**' in pat "
  534. "else [p for p in glob.glob(pat) if os.path.isfile(p)];"
  535. "mt=sorted([(p,os.path.getmtime(p)) for p in paths],key=lambda x:-x[1]);"
  536. "print(json.dumps([p for p,_ in mt[:n]]))"
  537. )
  538. code, out, err = await self.async_exec_argv(
  539. ["python3", "-c", script],
  540. workdir=sp,
  541. environment={"GW_GLOB_CFG": cfg},
  542. )
  543. if code != 0:
  544. return ToolResult(
  545. title="glob 失败",
  546. output=err.decode("utf-8", errors="replace") or out.decode("utf-8", errors="replace"),
  547. error="glob_failed",
  548. )
  549. try:
  550. file_paths: List[str] = json.loads(out.decode("utf-8") or "[]")
  551. except json.JSONDecodeError:
  552. return ToolResult(title="glob 解析失败", output=out.decode("utf-8", errors="replace"), error="bad_json")
  553. truncated = len(file_paths) > GLOB_LIMIT
  554. file_paths = file_paths[:GLOB_LIMIT]
  555. if not file_paths:
  556. output = "未找到匹配的文件"
  557. else:
  558. output = "\n".join(file_paths)
  559. if truncated:
  560. output += "\n\n(结果已截断。考虑使用更具体的路径或模式。)"
  561. return ToolResult(
  562. title=f"Glob: {pattern}",
  563. output=output,
  564. metadata={"count": len(file_paths), "truncated": truncated, "workspace_container": True},
  565. )
  566. async def tool_grep(
  567. self,
  568. pattern: str,
  569. path: Optional[str],
  570. include: Optional[str],
  571. ) -> ToolResult:
  572. wd = GatewayExecResolver.workdir(self._ge)
  573. search_path = GatewayExecResolver.resolve_path(self._ge, path, is_dir=True) if path else wd
  574. if not search_path:
  575. return ToolResult(title="路径无效", output="搜索目录无效。", error="invalid_path")
  576. if not self.sync_path_exists(search_path, is_dir=True):
  577. return ToolResult(title="目录不存在", output=f"搜索目录不存在: {path}", error="Directory not found")
  578. args: List[str] = [
  579. "rg", "-nH", "--hidden", "--follow", "--no-messages",
  580. "--field-match-separator=|", "--regexp", pattern,
  581. ]
  582. if include:
  583. args.extend(["--glob", include])
  584. args.append(search_path)
  585. code, stdout_b, stderr_b = await self.async_exec_argv(args, workdir=search_path)
  586. if code == 1:
  587. matches: List[Tuple[str, int, str]] = []
  588. elif code != 0 and code != 2:
  589. return ToolResult(
  590. title="ripgrep 失败",
  591. output=stderr_b.decode("utf-8", errors="replace"),
  592. error="rg_failed",
  593. )
  594. else:
  595. matches = []
  596. for line in stdout_b.decode("utf-8", errors="replace").strip().split("\n"):
  597. if not line:
  598. continue
  599. parts = line.split("|", 2)
  600. if len(parts) < 3:
  601. continue
  602. file_path_str, line_num_str, line_text = parts
  603. try:
  604. matches.append((file_path_str, int(line_num_str), line_text))
  605. except ValueError:
  606. continue
  607. matches.sort(key=lambda x: x[0], reverse=True)
  608. truncated = len(matches) > GREP_LIMIT
  609. matches = matches[:GREP_LIMIT]
  610. if not matches:
  611. output = "未找到匹配"
  612. else:
  613. output = f"找到 {len(matches)} 个匹配\n"
  614. current_file = None
  615. for file_path_str, line_num, line_text in matches:
  616. if current_file != file_path_str:
  617. if current_file is not None:
  618. output += "\n"
  619. current_file = file_path_str
  620. output += f"\n{file_path_str}:\n"
  621. if len(line_text) > 2000:
  622. line_text = line_text[:2000] + "..."
  623. output += f" Line {line_num}: {line_text}\n"
  624. if truncated:
  625. output += "\n(结果已截断。考虑使用更具体的路径或模式。)"
  626. return ToolResult(
  627. title=f"搜索: {pattern}",
  628. output=output,
  629. metadata={"matches": len(matches), "truncated": truncated, "pattern": pattern, "workspace_container": True},
  630. )
  631. # ---------------------------------------------------------------------------
  632. # 注册表:bash / 文件工具分发
  633. # ---------------------------------------------------------------------------
  634. class BashGatewayDispatcher:
  635. """将 ``bash_command`` 覆盖为:有 ``gateway_exec`` 时走 ``DockerWorkspaceClient.run_bash_tool``。"""
  636. _builtin: ClassVar[Callable[..., Coroutine[Any, Any, ToolResult]] | None] = None
  637. _installed: ClassVar[bool] = False
  638. @classmethod
  639. def install(cls, registry: ToolRegistry) -> None:
  640. if cls._installed:
  641. return
  642. entry = registry._tools.get("bash_command")
  643. if not entry:
  644. logger.warning("docker_runner: bash_command 未注册,跳过覆盖")
  645. return
  646. cls._builtin = entry["func"]
  647. schema = entry["schema"]
  648. hidden = list(entry.get("hidden_params") or ["context"])
  649. dispatch = cls._make_dispatch()
  650. registry.register(
  651. dispatch,
  652. schema=schema,
  653. hidden_params=hidden,
  654. inject_params=dict(entry.get("inject_params") or {}),
  655. requires_confirmation=entry["ui_metadata"].get("requires_confirmation", False),
  656. editable_params=list(entry["ui_metadata"].get("editable_params") or []),
  657. display=dict(entry["ui_metadata"].get("display") or {}),
  658. url_patterns=entry.get("url_patterns"),
  659. )
  660. cls._installed = True
  661. logger.info("bash_command 已启用 gateway_exec → docker exec 分发")
  662. @classmethod
  663. def _make_dispatch(cls) -> Callable[..., Coroutine[Any, Any, ToolResult]]:
  664. async def bash_command(
  665. command: str,
  666. timeout: Optional[int] = None,
  667. workdir: Optional[str] = None,
  668. env: Optional[Dict[str, str]] = None,
  669. description: str = "",
  670. context: Optional[ToolContext] = None,
  671. ) -> ToolResult:
  672. ge = GatewayExecResolver.effective(context)
  673. if ge:
  674. ws = DockerWorkspaceClient(ge)
  675. if ws.container_id():
  676. return await ws.run_bash_tool(
  677. command,
  678. timeout=timeout,
  679. workdir=workdir,
  680. env=env,
  681. description=description,
  682. )
  683. if cls._builtin is None:
  684. return ToolResult(title="内部错误", output="builtin bash_command 未初始化", error="no_builtin")
  685. return await cls._builtin(
  686. command=command,
  687. timeout=timeout,
  688. workdir=workdir,
  689. env=env,
  690. description=description,
  691. context=context,
  692. )
  693. bash_command.__name__ = "bash_command"
  694. bash_command.__doc__ = (
  695. "执行 bash 命令(Trace.gateway_exec 或 AGENT_DEFAULT_DOCKER_CONTAINER 时在容器内 docker exec)"
  696. )
  697. return bash_command
  698. class WorkspaceFileToolsDispatcher:
  699. """将 read/write/edit/glob/grep 在有 ``gateway_exec`` 时转发到 ``DockerWorkspaceClient``。"""
  700. _orig: ClassVar[dict[str, Callable[..., Coroutine[Any, Any, ToolResult]]]] = {}
  701. _installed: ClassVar[bool] = False
  702. @classmethod
  703. def install(cls, registry: ToolRegistry) -> None:
  704. if cls._installed:
  705. return
  706. async def read_file(
  707. file_path: str,
  708. offset: int = 0,
  709. limit: int = DEFAULT_READ_LIMIT,
  710. context: Optional[ToolContext] = None,
  711. ) -> ToolResult:
  712. ge = GatewayExecResolver.effective(context)
  713. parsed = urlparse(file_path)
  714. if parsed.scheme in ("http", "https"):
  715. return await cls._orig["read_file"](file_path=file_path, offset=offset, limit=limit, context=context)
  716. if ge and ge.get("docker_container"):
  717. return await DockerWorkspaceClient(ge).tool_read_file(file_path, offset, limit)
  718. return await cls._orig["read_file"](file_path=file_path, offset=offset, limit=limit, context=context)
  719. async def write_file(
  720. file_path: str,
  721. content: str,
  722. append: bool = False,
  723. context: Optional[ToolContext] = None,
  724. ) -> ToolResult:
  725. ge = GatewayExecResolver.effective(context)
  726. if ge and ge.get("docker_container"):
  727. return await DockerWorkspaceClient(ge).tool_write_file(file_path, content, append)
  728. return await cls._orig["write_file"](file_path=file_path, content=content, append=append, context=context)
  729. async def edit_file(
  730. file_path: str,
  731. old_string: str,
  732. new_string: str,
  733. replace_all: bool = False,
  734. context: Optional[ToolContext] = None,
  735. ) -> ToolResult:
  736. ge = GatewayExecResolver.effective(context)
  737. if ge and ge.get("docker_container"):
  738. return await DockerWorkspaceClient(ge).tool_edit_file(
  739. file_path, old_string, new_string, replace_all
  740. )
  741. return await cls._orig["edit_file"](
  742. file_path=file_path,
  743. old_string=old_string,
  744. new_string=new_string,
  745. replace_all=replace_all,
  746. context=context,
  747. )
  748. async def glob_files(
  749. pattern: str,
  750. path: Optional[str] = None,
  751. context: Optional[ToolContext] = None,
  752. ) -> ToolResult:
  753. ge = GatewayExecResolver.effective(context)
  754. if ge and ge.get("docker_container"):
  755. return await DockerWorkspaceClient(ge).tool_glob(pattern, path)
  756. return await cls._orig["glob_files"](pattern=pattern, path=path, context=context)
  757. async def grep_content(
  758. pattern: str,
  759. path: Optional[str] = None,
  760. include: Optional[str] = None,
  761. context: Optional[ToolContext] = None,
  762. ) -> ToolResult:
  763. ge = GatewayExecResolver.effective(context)
  764. if ge and ge.get("docker_container"):
  765. return await DockerWorkspaceClient(ge).tool_grep(pattern, path, include)
  766. orig = cls._orig["grep_content"]
  767. return await orig(pattern=pattern, path=path, include=include, context=context)
  768. read_file.__name__ = "read_file"
  769. write_file.__name__ = "write_file"
  770. edit_file.__name__ = "edit_file"
  771. glob_files.__name__ = "glob_files"
  772. grep_content.__name__ = "grep_content"
  773. for name, fn in [
  774. ("read_file", read_file),
  775. ("write_file", write_file),
  776. ("edit_file", edit_file),
  777. ("glob_files", glob_files),
  778. ("grep_content", grep_content),
  779. ]:
  780. cls._register_override(registry, name, fn)
  781. cls._installed = True
  782. logger.info("read/write/edit/glob/grep 已启用 gateway_exec → Workspace 容器分发")
  783. @classmethod
  784. def _register_override(
  785. cls,
  786. registry: ToolRegistry,
  787. name: str,
  788. dispatch: Callable[..., Coroutine[Any, Any, ToolResult]],
  789. ) -> None:
  790. entry = registry._tools.get(name)
  791. if not entry:
  792. logger.warning("docker_runner: 工具 %s 未注册,跳过覆盖", name)
  793. return
  794. cls._orig[name] = entry["func"]
  795. registry.register(
  796. dispatch,
  797. schema=entry["schema"],
  798. hidden_params=list(entry.get("hidden_params") or []),
  799. inject_params=dict(entry.get("inject_params") or {}),
  800. requires_confirmation=entry["ui_metadata"].get("requires_confirmation", False),
  801. editable_params=list(entry["ui_metadata"].get("editable_params") or []),
  802. display=dict(entry["ui_metadata"].get("display") or {}),
  803. url_patterns=entry.get("url_patterns"),
  804. )
  805. def install_bash_gateway_dispatch(registry: ToolRegistry) -> None:
  806. BashGatewayDispatcher.install(registry)
  807. def install_workspace_file_tools_dispatch(registry: ToolRegistry) -> None:
  808. WorkspaceFileToolsDispatcher.install(registry)
  809. __all__ = [
  810. "GatewayExecResolver",
  811. "DockerWorkspaceClient",
  812. "BashGatewayDispatcher",
  813. "WorkspaceFileToolsDispatcher",
  814. "active_gateway_exec",
  815. "gateway_exec_from_tool_context",
  816. "effective_gateway_exec",
  817. "container_workdir",
  818. "container_user",
  819. "resolve_container_path",
  820. "install_bash_gateway_dispatch",
  821. "install_workspace_file_tools_dispatch",
  822. ]