local_runner.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. """uv 本地环境管理 — 创建/运行/管理 uv 虚拟环境"""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. import subprocess
  7. from pathlib import Path
  8. from typing import Any
  9. from tool_agent.config import settings
  10. from tool_agent.models import ToolMeta
  11. logger = logging.getLogger(__name__)
  12. class LocalRunner:
  13. """uv 本地工具运行器 + 环境管理"""
  14. def __init__(self) -> None:
  15. self._background_procs: list[subprocess.Popen] = []
  16. self._proc_lock = asyncio.Lock() # 进程列表锁
  17. async def cleanup_background(self) -> int:
  18. """杀掉所有后台进程,返回清理数量"""
  19. async with self._proc_lock:
  20. killed = 0
  21. for proc in self._background_procs:
  22. try:
  23. proc.kill()
  24. proc.wait(timeout=5)
  25. killed += 1
  26. except Exception:
  27. pass
  28. self._background_procs.clear()
  29. if killed:
  30. logger.info(f"Cleaned up {killed} background processes")
  31. return killed
  32. # ---- 环境创建与管理 ----
  33. def create_project(
  34. self,
  35. name: str,
  36. path: Path | None = None,
  37. python_version: str = "3.12",
  38. ) -> dict[str, Any]:
  39. """创建新的 uv 项目
  40. Args:
  41. name: 项目名称
  42. path: 项目路径,默认在 tools/local/{name}
  43. python_version: Python 版本,默认 3.12
  44. """
  45. project_dir = path or (settings.tools_dir / "local" / name)
  46. project_dir.mkdir(parents=True, exist_ok=True)
  47. try:
  48. # uv init 创建项目
  49. result = subprocess.run(
  50. ["uv", "init", "--name", name, "--python", python_version],
  51. cwd=str(project_dir),
  52. capture_output=True,
  53. text=True,
  54. timeout=60,
  55. )
  56. if result.returncode != 0:
  57. return {"status": "error", "error": result.stderr, "stdout": result.stdout}
  58. # 清理 uv init 自动创建的 git 仓库
  59. import shutil
  60. git_dir = project_dir / ".git"
  61. gitignore = project_dir / ".gitignore"
  62. if git_dir.exists():
  63. shutil.rmtree(git_dir)
  64. if gitignore.exists():
  65. gitignore.unlink()
  66. logger.info(f"Created uv project: {project_dir}")
  67. return {
  68. "status": "success",
  69. "project_dir": str(project_dir),
  70. "message": f"Project '{name}' created with Python {python_version}",
  71. }
  72. except Exception as e:
  73. return {"status": "error", "error": str(e)}
  74. def create_venv(self, project_dir: Path, python_version: str = "3.12") -> dict[str, Any]:
  75. """在指定目录创建 venv
  76. Args:
  77. project_dir: 项目目录
  78. python_version: Python 版本
  79. """
  80. try:
  81. result = subprocess.run(
  82. ["uv", "venv", "--python", python_version],
  83. cwd=str(project_dir),
  84. capture_output=True,
  85. text=True,
  86. timeout=120,
  87. )
  88. if result.returncode != 0:
  89. return {"status": "error", "error": result.stderr}
  90. logger.info(f"Created venv in: {project_dir}")
  91. return {
  92. "status": "success",
  93. "venv_path": str(project_dir / ".venv"),
  94. "message": f"Venv created with Python {python_version}",
  95. }
  96. except Exception as e:
  97. return {"status": "error", "error": str(e)}
  98. def add_dependency(
  99. self,
  100. project_dir: Path,
  101. package: str,
  102. dev: bool = False,
  103. ) -> dict[str, Any]:
  104. """添加依赖
  105. Args:
  106. project_dir: 项目目录
  107. package: 包名,如 "flask" 或 "flask>=2.0"
  108. dev: 是否为开发依赖
  109. """
  110. cmd = ["uv", "add", package]
  111. if dev:
  112. cmd.append("--dev")
  113. try:
  114. result = subprocess.run(
  115. cmd,
  116. cwd=str(project_dir),
  117. capture_output=True,
  118. text=True,
  119. timeout=120,
  120. )
  121. if result.returncode != 0:
  122. return {"status": "error", "error": result.stderr, "stdout": result.stdout}
  123. logger.info(f"Added dependency '{package}' to {project_dir}")
  124. return {
  125. "status": "success",
  126. "package": package,
  127. "message": f"Added {'dev ' if dev else ''}dependency: {package}",
  128. }
  129. except Exception as e:
  130. return {"status": "error", "error": str(e)}
  131. def remove_dependency(self, project_dir: Path, package: str) -> dict[str, Any]:
  132. """移除依赖"""
  133. try:
  134. result = subprocess.run(
  135. ["uv", "remove", package],
  136. cwd=str(project_dir),
  137. capture_output=True,
  138. text=True,
  139. timeout=60,
  140. )
  141. if result.returncode != 0:
  142. return {"status": "error", "error": result.stderr}
  143. return {"status": "success", "message": f"Removed dependency: {package}"}
  144. except Exception as e:
  145. return {"status": "error", "error": str(e)}
  146. def sync_dependencies(self, project_dir: Path) -> dict[str, Any]:
  147. """同步依赖(uv sync)"""
  148. try:
  149. result = subprocess.run(
  150. ["uv", "sync"],
  151. cwd=str(project_dir),
  152. capture_output=True,
  153. text=True,
  154. timeout=300,
  155. )
  156. if result.returncode != 0:
  157. return {"status": "error", "error": result.stderr}
  158. return {"status": "success", "message": "Dependencies synced", "output": result.stdout}
  159. except Exception as e:
  160. return {"status": "error", "error": str(e)}
  161. def run_command(
  162. self,
  163. project_dir: Path,
  164. command: str,
  165. timeout: int = 120,
  166. background: bool = False,
  167. log_file: str = "last_run.log"
  168. ) -> dict[str, Any]:
  169. """在项目环境中运行命令,并同步写入日志文件"""
  170. # 日志统一写到 tests/ 目录
  171. tests_dir = project_dir / "tests"
  172. tests_dir.mkdir(parents=True, exist_ok=True)
  173. log_path = tests_dir / log_file
  174. try:
  175. # 拼接 uv run 命令
  176. full_cmd = ["uv", "run", "--directory", str(project_dir)] + command.split()
  177. if background:
  178. bg_log = tests_dir / f"background_{command.split()[0]}.log"
  179. with open(bg_log, "w") as f:
  180. proc = subprocess.Popen(
  181. full_cmd,
  182. stdout=f,
  183. stderr=subprocess.STDOUT,
  184. text=True,
  185. )
  186. self._background_procs.append(proc)
  187. return {
  188. "status": "success",
  189. "message": f"Command started in background (PID {proc.pid})",
  190. "pid": proc.pid,
  191. "log_file": str(bg_log),
  192. }
  193. # 使用 subprocess.run 并捕获输出
  194. result = subprocess.run(
  195. full_cmd,
  196. capture_output=True,
  197. text=True,
  198. timeout=timeout,
  199. )
  200. # --- 持久化日志 ---
  201. with open(log_path, "w", encoding="utf-8") as f:
  202. f.write(f"Command: {command}\n")
  203. f.write(f"Exit Code: {result.returncode}\n")
  204. f.write("--- STDOUT ---\n")
  205. f.write(result.stdout)
  206. f.write("\n--- STDERR ---\n")
  207. f.write(result.stderr)
  208. return {
  209. "exit_code": result.returncode,
  210. "stdout": result.stdout,
  211. "stderr": result.stderr,
  212. "log_path": str(log_path)
  213. }
  214. except subprocess.TimeoutExpired as e:
  215. # 超时也记录已捕获的部分输出
  216. return {"status": "error", "error": f"Timeout", "stdout": e.stdout, "stderr": e.stderr}
  217. def run_python(
  218. self,
  219. project_dir: Path,
  220. script: str,
  221. timeout: int = 120,
  222. ) -> dict[str, Any]:
  223. """在项目环境中运行 Python 脚本
  224. Args:
  225. project_dir: 项目目录
  226. script: Python 脚本路径(相对于项目目录)
  227. timeout: 超时秒数
  228. """
  229. return self.run_command(project_dir, f"python {script}", timeout)
  230. def run_code(
  231. self,
  232. project_dir: Path,
  233. code: str,
  234. timeout: int = 60,
  235. ) -> dict[str, Any]:
  236. """在项目环境中运行 Python 代码片段
  237. Args:
  238. project_dir: 项目目录
  239. code: Python 代码
  240. timeout: 超时秒数
  241. """
  242. try:
  243. result = subprocess.run(
  244. ["uv", "run", "--directory", str(project_dir), "python", "-c", code],
  245. capture_output=True,
  246. text=True,
  247. timeout=timeout,
  248. )
  249. return {
  250. "exit_code": result.returncode,
  251. "stdout": result.stdout,
  252. "stderr": result.stderr,
  253. }
  254. except subprocess.TimeoutExpired:
  255. return {"status": "error", "error": f"Code timeout after {timeout}s"}
  256. except Exception as e:
  257. return {"status": "error", "error": str(e)}
  258. def list_dependencies(self, project_dir: Path) -> dict[str, Any]:
  259. """列出项目依赖"""
  260. try:
  261. result = subprocess.run(
  262. ["uv", "pip", "list", "--format", "json"],
  263. cwd=str(project_dir),
  264. capture_output=True,
  265. text=True,
  266. timeout=30,
  267. )
  268. if result.returncode != 0:
  269. return {"status": "error", "error": result.stderr}
  270. packages = json.loads(result.stdout) if result.stdout else []
  271. return {"status": "success", "packages": packages}
  272. except Exception as e:
  273. return {"status": "error", "error": str(e)}
  274. def get_python_version(self, project_dir: Path) -> dict[str, Any]:
  275. """获取项目 Python 版本"""
  276. result = self.run_code(project_dir, "import sys; print(sys.version)")
  277. if result.get("exit_code") == 0:
  278. return {"status": "success", "version": result["stdout"].strip()}
  279. return {"status": "error", "error": result.get("stderr", result.get("error"))}
  280. # ---- 工具调用(原有功能) ----
  281. async def run(self, tool: ToolMeta, params: dict[str, Any], stream: bool = False) -> dict[str, Any]:
  282. """调用本地工具(通过 stdio JSON 协议)"""
  283. tool_dir = settings.tools_dir / "local" / tool.tool_id
  284. request = json.dumps({"action": "run", "params": params, "stream": stream})
  285. try:
  286. result = subprocess.run(
  287. ["uv", "run", "--directory", str(tool_dir), "python", "main.py"],
  288. input=request,
  289. capture_output=True,
  290. text=True,
  291. timeout=60,
  292. )
  293. if result.returncode != 0:
  294. return {"status": "error", "error": result.stderr}
  295. return json.loads(result.stdout)
  296. except subprocess.TimeoutExpired:
  297. return {"status": "error", "error": "timeout"}
  298. except Exception as e:
  299. return {"status": "error", "error": str(e)}
  300. async def health_check(self, tool: ToolMeta) -> bool:
  301. """工具健康检查"""
  302. tool_dir = settings.tools_dir / "local" / tool.tool_id
  303. request = json.dumps({"action": "health"})
  304. try:
  305. result = subprocess.run(
  306. ["uv", "run", "--directory", str(tool_dir), "python", "main.py"],
  307. input=request,
  308. capture_output=True,
  309. text=True,
  310. timeout=10,
  311. )
  312. data = json.loads(result.stdout)
  313. return data.get("healthy", False)
  314. except Exception:
  315. return False
  316. def read_logs(self, project_dir: Path, log_file: str = "last_run.log") -> dict[str, Any]:
  317. """读取项目内的日志文件"""
  318. log_path = project_dir / log_file
  319. if not log_path.exists():
  320. return {"status": "error", "error": "Log file not found"}
  321. try:
  322. content = log_path.read_text(encoding="utf-8")
  323. return {"status": "success", "content": content}
  324. except Exception as e:
  325. return {"status": "error", "error": str(e)}