docker_runner.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965
  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. # get_archive 流在部分 SDK/传输下会产出 str,不能 b"".join;str 用 latin-1 按字节还原
  213. parts: list[bytes] = []
  214. for chunk in stream:
  215. if isinstance(chunk, (bytes, bytearray, memoryview)):
  216. parts.append(bytes(chunk))
  217. elif isinstance(chunk, str):
  218. parts.append(chunk.encode("latin-1"))
  219. else:
  220. parts.append(bytes(chunk))
  221. raw = b"".join(parts)
  222. if raw:
  223. try:
  224. bio = io.BytesIO(raw)
  225. with tarfile.open(fileobj=bio, mode="r") as tar:
  226. member = tar.next()
  227. if member is None:
  228. raise tarfile.ReadError("empty archive")
  229. if member.isdir():
  230. raise IsADirectoryError(container_path)
  231. ef = tar.extractfile(member)
  232. if ef is None:
  233. return b""
  234. return ef.read()
  235. except tarfile.ReadError as e:
  236. logger.debug(
  237. "get_archive tar parse failed path=%s nbytes=%s: %s",
  238. container_path,
  239. len(raw),
  240. e,
  241. )
  242. code, out, err = self.sync_exec_argv(["cat", "--", container_path], workdir="/")
  243. err_t = err.decode("utf-8", errors="replace") if err else ""
  244. if code != 0:
  245. if "Is a directory" in err_t:
  246. raise IsADirectoryError(container_path)
  247. if code in (1, 127) or "No such file" in err_t or "Cannot open" in err_t:
  248. raise FileNotFoundError(container_path)
  249. raise RuntimeError(f"read_file fallback cat failed: {err_t[:500]}")
  250. return out
  251. @staticmethod
  252. def _posixpath_dir(p: str) -> str:
  253. return os.path.dirname(p.replace("\\", "/"))
  254. @staticmethod
  255. def _posixpath_basename(p: str) -> str:
  256. return os.path.basename(p.replace("\\", "/"))
  257. def sync_write_file_bytes(self, container_path: str, data: bytes) -> None:
  258. ct = self._docker_container()
  259. parent = self._posixpath_dir(container_path) or "/"
  260. base = self._posixpath_basename(container_path)
  261. if not base:
  262. raise ValueError("invalid container_path")
  263. code, _out, err = self.sync_exec_argv(
  264. ["mkdir", "-p", parent],
  265. workdir="/",
  266. )
  267. if code != 0:
  268. raise RuntimeError(
  269. f"mkdir -p failed: {parent!r} code={code} stderr={err.decode('utf-8', errors='replace')}"
  270. )
  271. tar_stream = io.BytesIO()
  272. with tarfile.open(fileobj=tar_stream, mode="w") as tar:
  273. ti = tarfile.TarInfo(name=base)
  274. ti.size = len(data)
  275. ti.mode = 0o644
  276. tar.addfile(ti, io.BytesIO(data))
  277. tar_stream.seek(0)
  278. ok = ct.put_archive(parent, tar_stream)
  279. if not ok:
  280. raise RuntimeError(f"put_archive failed: {container_path!r}")
  281. async def async_read_file_bytes(self, container_path: str) -> bytes:
  282. loop = asyncio.get_running_loop()
  283. return await loop.run_in_executor(None, lambda: self.sync_read_file_bytes(container_path))
  284. async def async_write_file_bytes(self, container_path: str, data: bytes) -> None:
  285. loop = asyncio.get_running_loop()
  286. await loop.run_in_executor(None, lambda: self.sync_write_file_bytes(container_path, data))
  287. def sync_path_exists(self, container_path: str, *, is_dir: bool) -> bool:
  288. flag = "d" if is_dir else "f"
  289. code, _, _ = self.sync_exec_argv(
  290. ["test", "-" + flag, container_path],
  291. workdir="/",
  292. )
  293. return code == 0
  294. @classmethod
  295. def _is_binary_buffer(cls, data: bytes, suffix: str) -> bool:
  296. if suffix.lower() in cls._BINARY_EXTS:
  297. return True
  298. if not data:
  299. return False
  300. buf = data[:4096]
  301. if b"\x00" in buf:
  302. return True
  303. non_printable = sum(1 for b in buf if b < 9 or (13 < b < 32))
  304. return (non_printable / len(buf)) > 0.3 if buf else False
  305. MAX_BASH_OUT: ClassVar[int] = 50_000
  306. async def run_bash_tool(
  307. self,
  308. command: str,
  309. *,
  310. timeout: Optional[int],
  311. workdir: Optional[str],
  312. env: Optional[Dict[str, str]],
  313. description: str,
  314. ) -> ToolResult:
  315. _ = description
  316. timeout_sec = timeout if timeout is not None and timeout > 0 else 120
  317. container = self.container_id()
  318. if not container:
  319. return ToolResult(title="配置错误", output="gateway_exec 缺少 docker_container", error="missing_container")
  320. default_wd = GatewayExecResolver.workdir(self._ge)
  321. inner_wd = str(workdir).strip() if workdir else default_wd
  322. loop = asyncio.get_running_loop()
  323. try:
  324. code, stdout, stderr = await asyncio.wait_for(
  325. loop.run_in_executor(
  326. None,
  327. lambda: self.sync_exec_argv(
  328. ["bash", "-lc", command],
  329. workdir=inner_wd,
  330. environment=env,
  331. ),
  332. ),
  333. timeout=timeout_sec,
  334. )
  335. except asyncio.TimeoutError:
  336. return ToolResult(
  337. title="命令超时",
  338. output=f"docker exec 超时(>{timeout_sec}s): {command[:100]}",
  339. error="Timeout",
  340. metadata={"command": command, "timeout": timeout_sec},
  341. )
  342. except Exception as e:
  343. logger.exception("docker exec 失败 container=%s", container)
  344. return ToolResult(
  345. title="Docker 执行失败",
  346. output=(
  347. f"{e}\n\n请确认 API 容器已挂载 /var/run/docker.sock,"
  348. "且已安装 docker Python 包;容器名在 gateway_exec.docker_container。"
  349. ),
  350. error="docker_error",
  351. )
  352. stdout_text = stdout.decode("utf-8", errors="replace") if stdout else ""
  353. stderr_text = stderr.decode("utf-8", errors="replace") if stderr else ""
  354. truncated = False
  355. if len(stdout_text) > self.MAX_BASH_OUT:
  356. stdout_text = stdout_text[: self.MAX_BASH_OUT] + f"\n\n(输出被截断,总长度: {len(stdout_text)} 字符)"
  357. truncated = True
  358. parts: list[str] = []
  359. if stdout_text:
  360. parts.append(stdout_text)
  361. if stderr_text:
  362. parts.append("\n\n--- stderr ---\n" + stderr_text)
  363. output = "\n".join(parts) if parts else "(命令无输出)"
  364. ok = code == 0
  365. meta: dict[str, Any] = {"exit_code": code, "docker_container": container, "truncated": truncated}
  366. return ToolResult(
  367. title=f"docker bash (exit {code})",
  368. output=output,
  369. error=None if ok else f"exit code {code}",
  370. metadata=meta,
  371. )
  372. async def tool_read_file(self, file_path: str, offset: int, limit: int) -> ToolResult:
  373. cpath = GatewayExecResolver.resolve_path(self._ge, file_path, is_dir=False)
  374. if not cpath:
  375. return ToolResult(
  376. title="路径无效",
  377. output="在 Workspace 模式下路径须相对于工作区根,或为工作区/映射项目根下的绝对路径。",
  378. error="invalid_path",
  379. )
  380. name = Path(cpath.replace("\\", "/")).name
  381. try:
  382. raw = await self.async_read_file_bytes(cpath)
  383. except FileNotFoundError:
  384. return ToolResult(
  385. title="文件未找到",
  386. output=f"文件不存在: {file_path}",
  387. error="File not found",
  388. )
  389. except IsADirectoryError:
  390. return ToolResult(
  391. title="路径错误",
  392. output=f"路径是目录: {file_path}",
  393. error="Is a directory",
  394. )
  395. except Exception as e:
  396. logger.exception("workspace read_file")
  397. return ToolResult(title="读取失败", output=str(e), error=str(e))
  398. mime_type, _ = mimetypes.guess_type(name)
  399. mime_type = mime_type or ""
  400. if mime_type.startswith("image/") and mime_type not in ("image/svg+xml", "image/vnd.fastbidsheet"):
  401. b64_data = base64.b64encode(raw).decode("ascii")
  402. return ToolResult(
  403. title=name,
  404. output=f"图片文件: {name} (MIME: {mime_type}, {len(raw)} bytes)",
  405. metadata={"mime_type": mime_type, "truncated": False, "workspace_container": True},
  406. images=[{"type": "base64", "media_type": mime_type, "data": b64_data}],
  407. )
  408. if mime_type == "application/pdf":
  409. return ToolResult(
  410. title=name,
  411. output=f"PDF 文件: {name}",
  412. metadata={"mime_type": mime_type, "truncated": False, "workspace_container": True},
  413. )
  414. if self._is_binary_buffer(raw, Path(name).suffix):
  415. return ToolResult(
  416. title="二进制文件",
  417. output=f"无法读取二进制文件: {name}",
  418. error="Binary file",
  419. )
  420. try:
  421. text = raw.decode("utf-8")
  422. except UnicodeDecodeError:
  423. return ToolResult(
  424. title="编码错误",
  425. output=f"无法解码文件(非 UTF-8): {name}",
  426. error="Encoding error",
  427. )
  428. lines_no_keep = text.splitlines()
  429. total_lines = len(lines_no_keep)
  430. end_line = min(offset + limit, total_lines)
  431. output_lines: list[str] = []
  432. total_bytes = 0
  433. truncated_by_bytes = False
  434. for i in range(offset, end_line):
  435. line = lines_no_keep[i]
  436. if len(line) > MAX_LINE_LENGTH:
  437. line = line[:MAX_LINE_LENGTH] + "..."
  438. line_bytes = len(line.encode("utf-8")) + (1 if output_lines else 0)
  439. if total_bytes + line_bytes > MAX_BYTES:
  440. truncated_by_bytes = True
  441. break
  442. output_lines.append(line)
  443. total_bytes += line_bytes
  444. formatted = [f"{offset + idx + 1:5d}| {ln}" for idx, ln in enumerate(output_lines)]
  445. output = "<file>\n" + "\n".join(formatted)
  446. last_read_line = offset + len(output_lines)
  447. has_more = total_lines > last_read_line
  448. truncated = has_more or truncated_by_bytes
  449. if truncated_by_bytes:
  450. output += f"\n\n(输出在 {MAX_BYTES} 字节处被截断。使用 offset 读取第 {last_read_line} 行之后)"
  451. elif has_more:
  452. output += f"\n\n(还有更多内容。使用 offset 读取第 {last_read_line} 行之后)"
  453. else:
  454. output += f"\n\n(文件结束 - 共 {total_lines} 行)"
  455. output += "\n</file>"
  456. preview = "\n".join(output_lines[:20])
  457. return ToolResult(
  458. title=name,
  459. output=output,
  460. metadata={
  461. "preview": preview,
  462. "truncated": truncated,
  463. "total_lines": total_lines,
  464. "read_lines": len(output_lines),
  465. "workspace_container": True,
  466. },
  467. )
  468. async def tool_write_file(self, file_path: str, content: str, append: bool) -> ToolResult:
  469. cpath = GatewayExecResolver.resolve_path(self._ge, file_path, is_dir=False)
  470. if not cpath:
  471. return ToolResult(title="路径无效", output="路径不在工作区内。", error="invalid_path")
  472. name = Path(cpath.replace("\\", "/")).name
  473. if self.sync_path_exists(cpath, is_dir=True):
  474. return ToolResult(title="路径错误", output=f"路径是目录: {file_path}", error="Path is a directory")
  475. existed = self.sync_path_exists(cpath, is_dir=False)
  476. old_content = ""
  477. if existed:
  478. try:
  479. old_content = (await self.async_read_file_bytes(cpath)).decode("utf-8", errors="replace")
  480. except Exception:
  481. old_content = ""
  482. if append and existed:
  483. new_content = old_content + content
  484. else:
  485. new_content = content
  486. if existed and old_content:
  487. diff = _create_diff(str(file_path), old_content, new_content)
  488. else:
  489. diff = f"(新建文件: {name})"
  490. try:
  491. await self.async_write_file_bytes(cpath, new_content.encode("utf-8"))
  492. except Exception as e:
  493. logger.exception("workspace write_file")
  494. return ToolResult(title="写入失败", output=str(e), error=str(e))
  495. lines = new_content.count("\n")
  496. if append and existed:
  497. operation = "追加内容到"
  498. elif existed:
  499. operation = "覆盖"
  500. else:
  501. operation = "创建"
  502. return ToolResult(
  503. title=name,
  504. output=f"文件写入成功 ({operation})\n\n{diff}",
  505. metadata={"existed": existed, "append": append, "lines": lines, "diff": diff, "workspace_container": True},
  506. long_term_memory=f"{operation}文件 {name}",
  507. )
  508. async def tool_edit_file(
  509. self,
  510. file_path: str,
  511. old_string: str,
  512. new_string: str,
  513. replace_all: bool,
  514. ) -> ToolResult:
  515. cpath = GatewayExecResolver.resolve_path(self._ge, file_path, is_dir=False)
  516. if not cpath:
  517. return ToolResult(title="路径无效", output="路径不在工作区内。", error="invalid_path")
  518. name = Path(cpath.replace("\\", "/")).name
  519. if not self.sync_path_exists(cpath, is_dir=False):
  520. return ToolResult(title="文件未找到", output=f"文件不存在: {file_path}", error="File not found")
  521. if self.sync_path_exists(cpath, is_dir=True):
  522. return ToolResult(title="路径错误", output=f"路径是目录: {file_path}", error="Path is a directory")
  523. try:
  524. content_old = (await self.async_read_file_bytes(cpath)).decode("utf-8")
  525. except Exception as e:
  526. return ToolResult(title="读取失败", output=str(e), error=str(e))
  527. try:
  528. content_new = edit_replace(content_old, old_string, new_string, replace_all)
  529. except ValueError as e:
  530. return ToolResult(title="替换失败", output=str(e), error=str(e))
  531. diff = _create_diff(file_path, content_old, content_new)
  532. try:
  533. await self.async_write_file_bytes(cpath, content_new.encode("utf-8"))
  534. except Exception as e:
  535. return ToolResult(title="写入失败", output=str(e), error=str(e))
  536. return ToolResult(
  537. title=name,
  538. output=f"编辑成功\n\n{diff}",
  539. metadata={
  540. "replace_all": replace_all,
  541. "workspace_container": True,
  542. "old_lines": content_old.count("\n"),
  543. "new_lines": content_new.count("\n"),
  544. },
  545. long_term_memory=f"编辑文件 {name}",
  546. )
  547. async def tool_glob(self, pattern: str, path: Optional[str]) -> ToolResult:
  548. wd = GatewayExecResolver.workdir(self._ge)
  549. sp = GatewayExecResolver.resolve_path(self._ge, path, is_dir=True) if path else wd
  550. if not sp:
  551. return ToolResult(title="路径无效", output="搜索目录无效。", error="invalid_path")
  552. if not self.sync_path_exists(sp, is_dir=True):
  553. return ToolResult(title="目录不存在", output=f"搜索目录不存在: {path}", error="Directory not found")
  554. cfg = json.dumps({"pattern": pattern, "root": sp, "fetch": GLOB_LIMIT + 1}, ensure_ascii=False)
  555. script = (
  556. "import glob,json,os;"
  557. "from pathlib import Path;"
  558. "c=json.loads(__import__('os').environ['GW_GLOB_CFG']);"
  559. "os.chdir(c['root']);pat=c['pattern'];n=int(c['fetch']);"
  560. "paths=[str(p) for p in Path('.').glob(pat) if p.is_file()] if '**' in pat "
  561. "else [p for p in glob.glob(pat) if os.path.isfile(p)];"
  562. "mt=sorted([(p,os.path.getmtime(p)) for p in paths],key=lambda x:-x[1]);"
  563. "print(json.dumps([p for p,_ in mt[:n]]))"
  564. )
  565. code, out, err = await self.async_exec_argv(
  566. ["python3", "-c", script],
  567. workdir=sp,
  568. environment={"GW_GLOB_CFG": cfg},
  569. )
  570. if code != 0:
  571. return ToolResult(
  572. title="glob 失败",
  573. output=err.decode("utf-8", errors="replace") or out.decode("utf-8", errors="replace"),
  574. error="glob_failed",
  575. )
  576. try:
  577. file_paths: List[str] = json.loads(out.decode("utf-8") or "[]")
  578. except json.JSONDecodeError:
  579. return ToolResult(title="glob 解析失败", output=out.decode("utf-8", errors="replace"), error="bad_json")
  580. truncated = len(file_paths) > GLOB_LIMIT
  581. file_paths = file_paths[:GLOB_LIMIT]
  582. if not file_paths:
  583. output = "未找到匹配的文件"
  584. else:
  585. output = "\n".join(file_paths)
  586. if truncated:
  587. output += "\n\n(结果已截断。考虑使用更具体的路径或模式。)"
  588. return ToolResult(
  589. title=f"Glob: {pattern}",
  590. output=output,
  591. metadata={"count": len(file_paths), "truncated": truncated, "workspace_container": True},
  592. )
  593. async def tool_grep(
  594. self,
  595. pattern: str,
  596. path: Optional[str],
  597. include: Optional[str],
  598. ) -> ToolResult:
  599. wd = GatewayExecResolver.workdir(self._ge)
  600. search_path = GatewayExecResolver.resolve_path(self._ge, path, is_dir=True) if path else wd
  601. if not search_path:
  602. return ToolResult(title="路径无效", output="搜索目录无效。", error="invalid_path")
  603. if not self.sync_path_exists(search_path, is_dir=True):
  604. return ToolResult(title="目录不存在", output=f"搜索目录不存在: {path}", error="Directory not found")
  605. args: List[str] = [
  606. "rg", "-nH", "--hidden", "--follow", "--no-messages",
  607. "--field-match-separator=|", "--regexp", pattern,
  608. ]
  609. if include:
  610. args.extend(["--glob", include])
  611. args.append(search_path)
  612. code, stdout_b, stderr_b = await self.async_exec_argv(args, workdir=search_path)
  613. if code == 1:
  614. matches: List[Tuple[str, int, str]] = []
  615. elif code != 0 and code != 2:
  616. return ToolResult(
  617. title="ripgrep 失败",
  618. output=stderr_b.decode("utf-8", errors="replace"),
  619. error="rg_failed",
  620. )
  621. else:
  622. matches = []
  623. for line in stdout_b.decode("utf-8", errors="replace").strip().split("\n"):
  624. if not line:
  625. continue
  626. parts = line.split("|", 2)
  627. if len(parts) < 3:
  628. continue
  629. file_path_str, line_num_str, line_text = parts
  630. try:
  631. matches.append((file_path_str, int(line_num_str), line_text))
  632. except ValueError:
  633. continue
  634. matches.sort(key=lambda x: x[0], reverse=True)
  635. truncated = len(matches) > GREP_LIMIT
  636. matches = matches[:GREP_LIMIT]
  637. if not matches:
  638. output = "未找到匹配"
  639. else:
  640. output = f"找到 {len(matches)} 个匹配\n"
  641. current_file = None
  642. for file_path_str, line_num, line_text in matches:
  643. if current_file != file_path_str:
  644. if current_file is not None:
  645. output += "\n"
  646. current_file = file_path_str
  647. output += f"\n{file_path_str}:\n"
  648. if len(line_text) > 2000:
  649. line_text = line_text[:2000] + "..."
  650. output += f" Line {line_num}: {line_text}\n"
  651. if truncated:
  652. output += "\n(结果已截断。考虑使用更具体的路径或模式。)"
  653. return ToolResult(
  654. title=f"搜索: {pattern}",
  655. output=output,
  656. metadata={"matches": len(matches), "truncated": truncated, "pattern": pattern, "workspace_container": True},
  657. )
  658. # ---------------------------------------------------------------------------
  659. # 注册表:bash / 文件工具分发
  660. # ---------------------------------------------------------------------------
  661. class BashGatewayDispatcher:
  662. """将 ``bash_command`` 覆盖为:有 ``gateway_exec`` 时走 ``DockerWorkspaceClient.run_bash_tool``。"""
  663. _builtin: ClassVar[Callable[..., Coroutine[Any, Any, ToolResult]] | None] = None
  664. _installed: ClassVar[bool] = False
  665. @classmethod
  666. def install(cls, registry: ToolRegistry) -> None:
  667. if cls._installed:
  668. return
  669. entry = registry._tools.get("bash_command")
  670. if not entry:
  671. logger.warning("docker_runner: bash_command 未注册,跳过覆盖")
  672. return
  673. cls._builtin = entry["func"]
  674. schema = entry["schema"]
  675. hidden = list(entry.get("hidden_params") or ["context"])
  676. dispatch = cls._make_dispatch()
  677. registry.register(
  678. dispatch,
  679. schema=schema,
  680. hidden_params=hidden,
  681. inject_params=dict(entry.get("inject_params") or {}),
  682. requires_confirmation=entry["ui_metadata"].get("requires_confirmation", False),
  683. editable_params=list(entry["ui_metadata"].get("editable_params") or []),
  684. display=dict(entry["ui_metadata"].get("display") or {}),
  685. url_patterns=entry.get("url_patterns"),
  686. )
  687. cls._installed = True
  688. logger.info("bash_command 已启用 gateway_exec → docker exec 分发")
  689. @classmethod
  690. def _make_dispatch(cls) -> Callable[..., Coroutine[Any, Any, ToolResult]]:
  691. async def bash_command(
  692. command: str,
  693. timeout: Optional[int] = None,
  694. workdir: Optional[str] = None,
  695. env: Optional[Dict[str, str]] = None,
  696. description: str = "",
  697. context: Optional[ToolContext] = None,
  698. ) -> ToolResult:
  699. ge = GatewayExecResolver.effective(context)
  700. if ge:
  701. ws = DockerWorkspaceClient(ge)
  702. if ws.container_id():
  703. return await ws.run_bash_tool(
  704. command,
  705. timeout=timeout,
  706. workdir=workdir,
  707. env=env,
  708. description=description,
  709. )
  710. if cls._builtin is None:
  711. return ToolResult(title="内部错误", output="builtin bash_command 未初始化", error="no_builtin")
  712. return await cls._builtin(
  713. command=command,
  714. timeout=timeout,
  715. workdir=workdir,
  716. env=env,
  717. description=description,
  718. context=context,
  719. )
  720. bash_command.__name__ = "bash_command"
  721. bash_command.__doc__ = (
  722. "执行 bash 命令(Trace.gateway_exec 或 AGENT_DEFAULT_DOCKER_CONTAINER 时在容器内 docker exec)"
  723. )
  724. return bash_command
  725. class WorkspaceFileToolsDispatcher:
  726. """将 read/write/edit/glob/grep 在有 ``gateway_exec`` 时转发到 ``DockerWorkspaceClient``。"""
  727. _orig: ClassVar[dict[str, Callable[..., Coroutine[Any, Any, ToolResult]]]] = {}
  728. _installed: ClassVar[bool] = False
  729. @classmethod
  730. def install(cls, registry: ToolRegistry) -> None:
  731. if cls._installed:
  732. return
  733. async def read_file(
  734. file_path: str,
  735. offset: int = 0,
  736. limit: int = DEFAULT_READ_LIMIT,
  737. context: Optional[ToolContext] = None,
  738. ) -> ToolResult:
  739. ge = GatewayExecResolver.effective(context)
  740. parsed = urlparse(file_path)
  741. if parsed.scheme in ("http", "https"):
  742. return await cls._orig["read_file"](file_path=file_path, offset=offset, limit=limit, context=context)
  743. if ge and ge.get("docker_container"):
  744. return await DockerWorkspaceClient(ge).tool_read_file(file_path, offset, limit)
  745. return await cls._orig["read_file"](file_path=file_path, offset=offset, limit=limit, context=context)
  746. async def write_file(
  747. file_path: str,
  748. content: str,
  749. append: bool = False,
  750. context: Optional[ToolContext] = None,
  751. ) -> ToolResult:
  752. ge = GatewayExecResolver.effective(context)
  753. if ge and ge.get("docker_container"):
  754. return await DockerWorkspaceClient(ge).tool_write_file(file_path, content, append)
  755. return await cls._orig["write_file"](file_path=file_path, content=content, append=append, context=context)
  756. async def edit_file(
  757. file_path: str,
  758. old_string: str,
  759. new_string: str,
  760. replace_all: bool = False,
  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_edit_file(
  766. file_path, old_string, new_string, replace_all
  767. )
  768. return await cls._orig["edit_file"](
  769. file_path=file_path,
  770. old_string=old_string,
  771. new_string=new_string,
  772. replace_all=replace_all,
  773. context=context,
  774. )
  775. async def glob_files(
  776. pattern: str,
  777. path: Optional[str] = None,
  778. context: Optional[ToolContext] = None,
  779. ) -> ToolResult:
  780. ge = GatewayExecResolver.effective(context)
  781. if ge and ge.get("docker_container"):
  782. return await DockerWorkspaceClient(ge).tool_glob(pattern, path)
  783. return await cls._orig["glob_files"](pattern=pattern, path=path, context=context)
  784. async def grep_content(
  785. pattern: str,
  786. path: Optional[str] = None,
  787. include: Optional[str] = None,
  788. context: Optional[ToolContext] = None,
  789. ) -> ToolResult:
  790. ge = GatewayExecResolver.effective(context)
  791. if ge and ge.get("docker_container"):
  792. return await DockerWorkspaceClient(ge).tool_grep(pattern, path, include)
  793. orig = cls._orig["grep_content"]
  794. return await orig(pattern=pattern, path=path, include=include, context=context)
  795. read_file.__name__ = "read_file"
  796. write_file.__name__ = "write_file"
  797. edit_file.__name__ = "edit_file"
  798. glob_files.__name__ = "glob_files"
  799. grep_content.__name__ = "grep_content"
  800. for name, fn in [
  801. ("read_file", read_file),
  802. ("write_file", write_file),
  803. ("edit_file", edit_file),
  804. ("glob_files", glob_files),
  805. ("grep_content", grep_content),
  806. ]:
  807. cls._register_override(registry, name, fn)
  808. cls._installed = True
  809. logger.info("read/write/edit/glob/grep 已启用 gateway_exec → Workspace 容器分发")
  810. @classmethod
  811. def _register_override(
  812. cls,
  813. registry: ToolRegistry,
  814. name: str,
  815. dispatch: Callable[..., Coroutine[Any, Any, ToolResult]],
  816. ) -> None:
  817. entry = registry._tools.get(name)
  818. if not entry:
  819. logger.warning("docker_runner: 工具 %s 未注册,跳过覆盖", name)
  820. return
  821. cls._orig[name] = entry["func"]
  822. registry.register(
  823. dispatch,
  824. schema=entry["schema"],
  825. hidden_params=list(entry.get("hidden_params") or []),
  826. inject_params=dict(entry.get("inject_params") or {}),
  827. requires_confirmation=entry["ui_metadata"].get("requires_confirmation", False),
  828. editable_params=list(entry["ui_metadata"].get("editable_params") or []),
  829. display=dict(entry["ui_metadata"].get("display") or {}),
  830. url_patterns=entry.get("url_patterns"),
  831. )
  832. def install_bash_gateway_dispatch(registry: ToolRegistry) -> None:
  833. BashGatewayDispatcher.install(registry)
  834. def install_workspace_file_tools_dispatch(registry: ToolRegistry) -> None:
  835. WorkspaceFileToolsDispatcher.install(registry)
  836. __all__ = [
  837. "GatewayExecResolver",
  838. "DockerWorkspaceClient",
  839. "BashGatewayDispatcher",
  840. "WorkspaceFileToolsDispatcher",
  841. "active_gateway_exec",
  842. "gateway_exec_from_tool_context",
  843. "effective_gateway_exec",
  844. "container_workdir",
  845. "container_user",
  846. "resolve_container_path",
  847. "install_bash_gateway_dispatch",
  848. "install_workspace_file_tools_dispatch",
  849. ]