- 重写子智能体执行核心,不再启动 easyagent Node.js 子进程 - 新增 modules/sub_agent/ 包集中管理子智能体逻辑 - 工具调用复用主进程 WebTerminal.handle_tool_call,自然经过沙箱/容器链路 - 子智能体模型独立读取 ~/.agents/<mode>/config/sub_agent_models.json - 支持 8 个工具:read_file/write_file/edit_file(replacements+replace_all)/run_command/web_search/extract_webpage/search_workspace/read_mediafile - 修复子智能体进度弹窗:标题颜色、write_file 显示、过滤非 progress 条目、统一滚动条样式 - 更新 AGENTS.md / CLAUDE.md 子智能体描述 - 新增 test/test_sub_agent_regression.py 回归测试
256 lines
9.2 KiB
Python
256 lines
9.2 KiB
Python
"""子智能体特有工具实现(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)}
|