import asyncio import json from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Set try: from config import ( OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, READ_TOOL_MAX_FILE_SIZE, TERMINAL_SANDBOX_MOUNT_PATH, TERMINAL_SANDBOX_MODE, TERMINAL_SANDBOX_CPUS, TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, CUSTOM_TOOLS_ENABLED, WORKSPACE_SKILLS_DIRNAME, WORKSPACE_MEMORY_DIRNAME, ) except ImportError: import sys project_root = Path(__file__).resolve().parents[2] if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from config import ( OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION, MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE, MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS, READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER, READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER, READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES, READ_TOOL_MAX_FILE_SIZE, TERMINAL_SANDBOX_MOUNT_PATH, TERMINAL_SANDBOX_MODE, TERMINAL_SANDBOX_CPUS, TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, CUSTOM_TOOLS_ENABLED, WORKSPACE_SKILLS_DIRNAME, WORKSPACE_MEMORY_DIRNAME, ) from modules.file_manager import FileManager from modules.search_engine import SearchEngine from modules.terminal_ops import TerminalOperator from modules.memory_manager import MemoryManager from modules.terminal_manager import TerminalManager from modules.todo_manager import TodoManager from modules.sub_agent import SubAgentManager from modules.webpage_extractor import extract_webpage_content, tavily_extract from modules.ocr_client import OCRClient from modules.easter_egg_manager import EasterEggManager from modules.personalization_manager import ( load_personalization_config, build_personalization_prompt, ) from modules.skills_manager import ( get_skills_catalog, build_skills_list, merge_enabled_skills, build_skills_prompt, infer_private_skills_dir, ) from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_executor import CustomToolExecutor from modules.container_monitor import collect_stats, inspect_state from core.tool_config import TOOL_CATEGORIES from utils.api_client import APIClient from utils.context_manager import ContextManager from utils.tool_result_formatter import format_tool_result_for_context from utils.logger import setup_logger from config.model_profiles import ( get_model_profile, get_model_prompt_replacements, get_model_context_window, ) logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True class MainTerminalToolsReadMixin: @staticmethod def _normalize_skill_name(skill_name: Any) -> str: raw = str(skill_name or "").strip() if not raw: return "" normalized = raw.replace("\\", "/").strip("/") prefix = f"{WORKSPACE_SKILLS_DIRNAME}/" if normalized.lower().startswith(prefix): normalized = normalized[len(prefix):] if normalized.lower().endswith("/skill.md"): normalized = normalized[: -len("/SKILL.md")] return normalized.strip() def _resolve_skill_id(self, skill_name: Any) -> Dict[str, Any]: normalized_input = self._normalize_skill_name(skill_name) if not normalized_input: return {"success": False, "error": "skill_name 不能为空"} try: personalization = load_personalization_config(self.data_dir) except Exception: personalization = {} catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) enabled_skills = merge_enabled_skills( personalization.get("enabled_skills") if isinstance(personalization, dict) else None, catalog, personalization.get("skills_catalog_snapshot") if isinstance(personalization, dict) else None, ) enabled_set = set(enabled_skills or []) filtered_catalog = [item for item in catalog if item.get("id") in enabled_set] if enabled_set else list(catalog) normalized_lower = normalized_input.lower() # 1) 优先按 skill id 精确匹配(忽略大小写) id_map = {str(item.get("id", "")).lower(): item.get("id") for item in filtered_catalog if item.get("id")} if normalized_lower in id_map: return {"success": True, "skill_id": id_map[normalized_lower]} # 2) 再按 label 匹配(忽略大小写) label_matches: List[str] = [] for item in filtered_catalog: label = str(item.get("label") or "").strip().lower() if label and label == normalized_lower and item.get("id"): label_matches.append(item["id"]) if len(label_matches) == 1: return {"success": True, "skill_id": label_matches[0]} if len(label_matches) > 1: return { "success": False, "error": f"skill_name 匹配到多个技能: {', '.join(sorted(label_matches))},请改用 skill id" } return {"success": False, "error": f"未找到技能: {normalized_input}"} def _handle_read_skill_tool(self, arguments: Dict) -> Dict: skill_name = arguments.get("skill_name") resolved = self._resolve_skill_id(skill_name) if not resolved.get("success"): return resolved skill_id = resolved["skill_id"] read_args = { "path": f"{WORKSPACE_SKILLS_DIRNAME}/{skill_id}/SKILL.md", "type": "read", } result = self._handle_read_tool(read_args) if not result.get("success"): return result result["skill_id"] = skill_id result["skill_name"] = skill_name return result def _handle_recall_project_memory(self, name: str) -> Dict: """处理 recall_project_memory:读取 .astrion/memory/{name}.md""" safe_name = str(name).strip() if not safe_name or "/" in safe_name or "\\" in safe_name: return {"success": False, "error": f"记忆名称不合法: {name}"} file_path = f"{WORKSPACE_MEMORY_DIRNAME}/{safe_name}.md" read_args = { "path": file_path, "type": "read", } result = self._handle_read_tool(read_args) if result.get("success"): result["memory_name"] = safe_name return result def _handle_search_project_memory(self, keywords: List[str], max_results: int = 5) -> Dict: """处理 search_project_memory:在 .astrion/memory/*.md 中做关键词全文检索。 评分:名称命中 +10,描述命中 +5,正文每行命中 +1(单关键词最多计 5 行)。 返回 top-N 结果,附匹配行片段(行号基于完整文件,可直接配合 read_file extract 使用)。 """ clean_keywords: List[str] = [] for kw in keywords or []: kw_text = str(kw or "").strip() if kw_text and kw_text not in clean_keywords: clean_keywords.append(kw_text) clean_keywords = clean_keywords[:5] if not clean_keywords: return {"success": False, "error": "search_project_memory 需要至少 1 个关键词"} max_results = self._clamp_int(max_results, 5, 1, 10) memory_dir = Path(self.project_path) / WORKSPACE_MEMORY_DIRNAME if not memory_dir.exists() or not memory_dir.is_dir(): empty_text = "项目记忆目录不存在,暂无项目记忆可检索。" return { "success": True, "count": 0, "keywords": clean_keywords, "results": [], "content": empty_text, "summary": empty_text, } lowered = [(kw, kw.lower()) for kw in clean_keywords] scored: List[Dict[str, Any]] = [] for md_file in sorted(memory_dir.glob("*.md")): try: text = md_file.read_text(encoding="utf-8") except Exception: continue lines = text.split("\n") name = md_file.stem description = "" body_start_idx = 0 # 0-based,正文起始行(跳过 frontmatter) if lines and lines[0].strip() == "---": for i in range(1, len(lines)): if lines[i].strip() == "---": for fm_line in lines[1:i]: fm_stripped = fm_line.strip() if fm_stripped.startswith("name:"): name = fm_stripped.split(":", 1)[1].strip() or name elif fm_stripped.startswith("description:"): description = fm_stripped.split(":", 1)[1].strip() body_start_idx = i + 1 break name_lower = name.lower() desc_lower = description.lower() body_lines = lines[body_start_idx:] score = 0 matched_keywords: List[str] = [] for kw, kw_lower in lowered: kw_score = 0 if kw_lower in name_lower: kw_score += 10 if kw_lower in desc_lower: kw_score += 5 body_hits = sum(1 for line in body_lines if kw_lower in line.lower()) kw_score += min(body_hits, 5) if kw_score > 0: score += kw_score matched_keywords.append(kw) if score <= 0: continue snippets: List[Dict[str, Any]] = [] for idx in range(body_start_idx, len(lines)): line_stripped = lines[idx].strip() if not line_stripped: continue line_lower = line_stripped.lower() if any(kw_lower in line_lower for _, kw_lower in lowered): snippet_text = line_stripped if len(line_stripped) <= 120 else line_stripped[:117] + "..." snippets.append({"line": idx + 1, "text": snippet_text}) if len(snippets) >= 3: break scored.append({ "file": md_file.name, "name": name, "description": description, "score": score, "matched_keywords": matched_keywords, "snippets": snippets, }) scored.sort(key=lambda item: (-item["score"], -len(item["matched_keywords"]), item["name"])) top = scored[:max_results] if not top: empty_text = ( f"未找到匹配的项目记忆(关键词:{'、'.join(clean_keywords)})。" "不要更换关键词重复检索;继续当前任务即可。" ) return { "success": True, "count": 0, "keywords": clean_keywords, "results": [], "content": empty_text, "summary": "未找到匹配的项目记忆", } content_lines = [f"找到 {len(top)} 个匹配的项目记忆(关键词:{'、'.join(clean_keywords)}):", ""] for rank, item in enumerate(top, start=1): content_lines.append(f"[{rank}] {item['name']}(.astrion/memory/{item['file']})") if item["description"]: content_lines.append(f" 描述:{item['description']}") if item["snippets"]: content_lines.append(" 匹配片段:") for snippet in item["snippets"]: content_lines.append(f" L{snippet['line']}: {snippet['text']}") content_lines.append("") content_lines.append("如需完整内容,使用 recall_project_memory 读取对应记忆。") content_text = "\n".join(content_lines).strip() return { "success": True, "count": len(top), "keywords": clean_keywords, "results": top, "content": content_text, "summary": f"找到 {len(top)} 个匹配的项目记忆", } @staticmethod def _clamp_int(value, default, min_value=None, max_value=None): """将输入转换为整数并限制范围。""" if value is None: return default try: num = int(value) except (TypeError, ValueError): return default if min_value is not None: num = max(min_value, num) if max_value is not None: num = min(max_value, num) return num @staticmethod def _parse_optional_line(value, field_name: str): """解析可选的行号参数。""" if value is None: return None, None try: number = int(value) except (TypeError, ValueError): return None, f"{field_name} 必须是整数" if number < 1: return None, f"{field_name} 必须大于等于1" return number, None @staticmethod def _truncate_text_block(text: str, max_chars: int): """对单段文本应用字符限制。""" if max_chars and len(text) > max_chars: return text[:max_chars], True, max_chars return text, False, len(text) @staticmethod def _limit_text_chunks(chunks: List[Dict], text_key: str, max_chars: int): """对多个文本片段应用全局字符限制。""" if max_chars is None or max_chars <= 0: return chunks, False, sum(len(chunk.get(text_key, "") or "") for chunk in chunks) remaining = max_chars limited_chunks: List[Dict] = [] truncated = False consumed = 0 for chunk in chunks: snippet = chunk.get(text_key, "") or "" snippet_len = len(snippet) chunk_copy = dict(chunk) if remaining <= 0: truncated = True break if snippet_len > remaining: chunk_copy[text_key] = snippet[:remaining] chunk_copy["truncated"] = True consumed += remaining limited_chunks.append(chunk_copy) truncated = True remaining = 0 break limited_chunks.append(chunk_copy) consumed += snippet_len remaining -= snippet_len return limited_chunks, truncated, consumed def _handle_read_tool(self, arguments: Dict) -> Dict: """集中处理 read_file 工具的三种模式。""" file_path = arguments.get("path") if not file_path: return {"success": False, "error": "缺少文件路径参数"} read_type = (arguments.get("type") or "read").lower() if read_type not in {"read", "search", "extract"}: return {"success": False, "error": f"未知的读取类型: {read_type}"} max_chars = self._clamp_int( arguments.get("max_chars"), READ_TOOL_DEFAULT_MAX_CHARS, 1, MAX_READ_FILE_CHARS ) base_result = { "success": True, "type": read_type, "path": None, "encoding": "utf-8", "max_chars": max_chars, "truncated": False } if read_type == "read": start_line, error = self._parse_optional_line(arguments.get("start_line"), "start_line") if error: return {"success": False, "error": error} end_line_val = arguments.get("end_line") end_line = None if end_line_val is not None: end_line, error = self._parse_optional_line(end_line_val, "end_line") if error: return {"success": False, "error": error} if start_line and end_line < start_line: return {"success": False, "error": "end_line 必须大于等于 start_line"} read_result = self.file_manager.read_text_segment( file_path, start_line=start_line, end_line=end_line, size_limit=READ_TOOL_MAX_FILE_SIZE ) if not read_result.get("success"): return read_result content, truncated, char_count = self._truncate_text_block(read_result["content"], max_chars) base_result.update({ "path": read_result["path"], "content": content, "line_start": read_result["line_start"], "line_end": read_result["line_end"], "total_lines": read_result["total_lines"], "file_size": read_result["size"], "char_count": char_count, "message": f"已读取 {read_result['path']} 的内容(行 {read_result['line_start']}~{read_result['line_end']})" }) base_result["truncated"] = truncated self.context_manager.load_file(read_result["path"]) return base_result if read_type == "search": query = arguments.get("query") if not query: return {"success": False, "error": "搜索模式需要提供 query 参数"} max_matches = self._clamp_int( arguments.get("max_matches"), READ_TOOL_DEFAULT_MAX_MATCHES, 1, READ_TOOL_MAX_MATCHES ) context_before = self._clamp_int( arguments.get("context_before"), READ_TOOL_DEFAULT_CONTEXT_BEFORE, 0, READ_TOOL_MAX_CONTEXT_BEFORE ) context_after = self._clamp_int( arguments.get("context_after"), READ_TOOL_DEFAULT_CONTEXT_AFTER, 0, READ_TOOL_MAX_CONTEXT_AFTER ) case_sensitive = bool(arguments.get("case_sensitive")) search_result = self.file_manager.search_text( file_path, query=query, max_matches=max_matches, context_before=context_before, context_after=context_after, case_sensitive=case_sensitive, size_limit=READ_TOOL_MAX_FILE_SIZE ) if not search_result.get("success"): return search_result matches = search_result["matches"] limited_matches, truncated, char_count = self._limit_text_chunks(matches, "snippet", max_chars) base_result.update({ "path": search_result["path"], "file_size": search_result["size"], "query": query, "max_matches": max_matches, "actual_matches": len(matches), "returned_matches": len(limited_matches), "context_before": context_before, "context_after": context_after, "case_sensitive": case_sensitive, "matches": limited_matches, "char_count": char_count, "message": f"在 {search_result['path']} 中搜索 \"{query}\",返回 {len(limited_matches)} 条结果" }) base_result["truncated"] = truncated return base_result # extract segments = arguments.get("segments") if not isinstance(segments, list) or not segments: return {"success": False, "error": "extract 模式需要提供 segments 数组"} extract_result = self.file_manager.extract_segments( file_path, segments=segments, size_limit=READ_TOOL_MAX_FILE_SIZE ) if not extract_result.get("success"): return extract_result limited_segments, truncated, char_count = self._limit_text_chunks( extract_result["segments"], "content", max_chars ) base_result.update({ "path": extract_result["path"], "segments": limited_segments, "file_size": extract_result["size"], "total_lines": extract_result["total_lines"], "segment_count": len(limited_segments), "char_count": char_count, "message": f"已从 {extract_result['path']} 抽取 {len(limited_segments)} 个片段" }) base_result["truncated"] = truncated self.context_manager.load_file(extract_result["path"]) return base_result