agent-Specialization/modules/sub_agent/tools.py
JOJO 9ed956518c refactor(sub_agent): 子智能体从 Node.js 子进程改为主进程内 Python 协程
- 重写子智能体执行核心,不再启动 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 回归测试
2026-06-20 00:26:45 +08:00

256 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""子智能体特有工具实现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)}