"""子智能体特有工具实现(search_workspace / read_mediafile)。 这些工具在主进程中没有同名工具,因此由子智能体管理器本地实现。 search_workspace 优先复用主进程的 run_command 调用 rg,回退到 Python 遍历; read_mediafile 直接读取项目内媒体文件并返回 base64。 """ from __future__ import annotations import base64 import fnmatch import json import mimetypes import re import shlex import shutil from pathlib import Path from typing import Any, Dict, List, Optional async def handle_search_workspace( project_path: Path, terminal: Any, arguments: Dict[str, Any], ) -> Dict[str, Any]: """search_workspace 实现。""" mode = arguments.get("mode") query = arguments.get("query") or "" root = arguments.get("root") or "." use_regex = bool(arguments.get("use_regex", False)) case_sensitive = bool(arguments.get("case_sensitive", False)) max_results = int(arguments.get("max_results", 20)) max_matches_per_file = int(arguments.get("max_matches_per_file", 3)) include_glob = arguments.get("include_glob") or ["**/*"] exclude_glob = arguments.get("exclude_glob") or [] max_file_size = arguments.get("max_file_size") if mode not in {"file", "content"}: return {"success": False, "error": "search_workspace mode 必须是 file 或 content"} root_path = (project_path / root).resolve() try: root_path.relative_to(project_path) except Exception: return {"success": False, "error": "搜索根目录必须位于项目目录内"} if mode == "file": return _search_workspace_file(project_path, root_path, query, use_regex, case_sensitive, max_results, include_glob, exclude_glob) rg_result = await _search_workspace_content_rg( project_path, root_path, query, use_regex, case_sensitive, max_results, max_matches_per_file, include_glob, exclude_glob, max_file_size, terminal ) if rg_result is not None: return rg_result return _search_workspace_content_python( project_path, root_path, query, use_regex, case_sensitive, max_results, max_matches_per_file, include_glob, exclude_glob, max_file_size ) def _search_workspace_file( project_path: Path, root_path: Path, query: str, use_regex: bool, case_sensitive: bool, max_results: int, include_glob: List[str], exclude_glob: List[str], ) -> Dict[str, Any]: try: pattern = re.compile(query, 0 if case_sensitive else re.IGNORECASE) if use_regex else None needle = query if case_sensitive else query.lower() matches = [] for item in _iter_files(project_path, root_path, include_glob, exclude_glob): name = item.name hay = name if case_sensitive else name.lower() ok = pattern.search(name) if pattern else needle in hay if ok: matches.append(str(item.relative_to(project_path))) if len(matches) >= max_results: break return { "success": True, "mode": "file", "root": str(root_path.relative_to(project_path)), "query": query, "matches": matches, } except Exception as exc: return {"success": False, "error": str(exc)} async def _search_workspace_content_rg( project_path: Path, root_path: Path, query: str, use_regex: bool, case_sensitive: bool, max_results: int, max_matches_per_file: int, include_glob: List[str], exclude_glob: List[str], max_file_size: Optional[int], terminal: Any, ) -> Optional[Dict[str, Any]]: if not shutil.which("rg"): return None try: args = ["rg", "--line-number", "--no-heading", "--color=never"] if not use_regex: args.append("--fixed-strings") if not case_sensitive: args.append("-i") if max_matches_per_file: args.append(f"--max-count={max_matches_per_file}") if max_file_size: args.append(f"--max-filesize={max_file_size}") for g in include_glob: args.extend(["-g", g]) for g in exclude_glob: args.extend(["-g", f"!{g}"]) args.extend([query, str(root_path)]) result_text = await terminal.handle_tool_call("run_command", {"command": shlex.join(args), "timeout": 60}) parsed = json.loads(result_text) if isinstance(result_text, str) else result_text if not parsed.get("success"): return None files_map: Dict[str, List[Dict[str, Any]]] = {} for line in (parsed.get("output") or "").splitlines(): if not line.strip(): continue parts = line.split(":", 2) if len(parts) < 3: continue file_path, line_num, snippet = parts[0], parts[1], parts[2] try: line_no = int(line_num) except ValueError: continue files_map.setdefault(file_path, []) if len(files_map[file_path]) < max_matches_per_file: files_map[file_path].append({"line": line_no, "snippet": snippet.strip()}) if len(files_map) >= max_results and len(files_map[file_path]) >= max_matches_per_file: break results = [{"file": f, "matches": ms} for f, ms in list(files_map.items())[:max_results]] return { "success": True, "mode": "content", "root": str(root_path.relative_to(project_path)), "query": query, "results": results, } except Exception: return None def _search_workspace_content_python( project_path: Path, root_path: Path, query: str, use_regex: bool, case_sensitive: bool, max_results: int, max_matches_per_file: int, include_glob: List[str], exclude_glob: List[str], max_file_size: Optional[int], ) -> Dict[str, Any]: try: pattern = re.compile(query, 0 if case_sensitive else re.IGNORECASE) if use_regex else None needle = query if case_sensitive else query.lower() results = [] for file_path in _iter_files(project_path, root_path, include_glob, exclude_glob): if max_file_size: try: if file_path.stat().st_size > max_file_size: continue except Exception: continue try: text = file_path.read_text(encoding="utf-8", errors="ignore") except Exception: continue lines = text.splitlines() matches = [] for idx, line in enumerate(lines, 1): hay = line if case_sensitive else line.lower() ok = pattern.search(line) if pattern else needle in hay if ok: matches.append({"line": idx, "snippet": line.strip()}) if len(matches) >= max_matches_per_file: break if matches: results.append({"file": str(file_path.relative_to(project_path)), "matches": matches}) if len(results) >= max_results: break return { "success": True, "mode": "content", "root": str(root_path.relative_to(project_path)), "query": query, "results": results, } except Exception as exc: return {"success": False, "error": str(exc)} def _iter_files(project_path: Path, root_path: Path, include_glob: List[str], exclude_glob: List[str]): """按 glob 规则遍历文件。""" for item in root_path.rglob("*"): if not item.is_file(): continue try: rel = item.relative_to(project_path) except Exception: continue rel_str = str(rel) included = any(fnmatch.fnmatch(rel_str, g) for g in include_glob) if not included: continue if any(fnmatch.fnmatch(rel_str, g) for g in exclude_glob): continue yield item async def handle_read_mediafile(project_path: Path, arguments: Dict[str, Any]) -> Dict[str, Any]: """read_mediafile 实现:直接读取项目内媒体文件并返回 base64。""" path = arguments.get("path") if not path: return {"success": False, "error": "path 不能为空"} try: abs_path = (project_path / path).resolve() abs_path.relative_to(project_path) except Exception: return {"success": False, "error": "非法路径,超出项目根目录"} if not abs_path.exists() or not abs_path.is_file(): return {"success": False, "error": f"文件不存在: {path}"} mime, _ = mimetypes.guess_type(str(abs_path)) if not mime or (not mime.startswith("image/") and not mime.startswith("video/")): return {"success": False, "error": "禁止的文件类型"} try: data = abs_path.read_bytes() b64 = base64.b64encode(data).decode("utf-8") file_type = "image" if mime.startswith("image/") else "video" return {"success": True, "path": path, "mime": mime, "type": file_type, "b64": b64} except Exception as exc: return {"success": False, "error": str(exc)}