agent-Specialization/core/main_terminal_parts/tools_read.py
JOJO a2cf547400 feat(i18n): 后端用户可见消息国际化(zh/en 双语 + ui_locale 偏好持久化)
- 新增 modules/i18n.py:tr() + 进程级语言缓存 + modules/i18n_messages/ 域文案包自动聚合
- 新增 29 个域文案包,共 1153 条双语 key;90+ 源文件 1146 处用户可见消息 tr 化
- ui_locale 存入 personalization.json(用户级共享),前后端双向同步
- 前端匹配点双语兼容(history/shared/ChatArea/taskPolling/upload 等正则)
- 修复语言判等陷阱:审批等待加稳定 code 字段;conversation.py 不存在判等改双语 helper
- 边界:日志/prompt 注入/子智能体工具回填/容器内嵌脚本不迁移
2026-08-29 07:58:29 +08:00

584 lines
26 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.

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,
PROJECT_AGENTS_SKILLS_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,
PROJECT_AGENTS_SKILLS_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,
)
from modules.i18n import tr
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("/")
for prefix in (f"{WORKSPACE_SKILLS_DIRNAME}/", f"{PROJECT_AGENTS_SKILLS_DIRNAME}/"):
if normalized.lower().startswith(prefix):
normalized = normalized[len(prefix):]
break
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": tr("tools_read.skill_name_empty")}
try:
personalization = load_personalization_config(self.data_dir)
except Exception:
personalization = {}
scan_project_agents = (
bool(personalization.get("agents_skills_scan_enabled", True))
if isinstance(personalization, dict)
else True
)
catalog = get_skills_catalog(
private_dir=infer_private_skills_dir(self.data_dir),
project_path=self.project_path,
scan_project_agents=scan_project_agents,
)
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()
def _resolve_item(item: Dict[str, str]) -> Dict[str, Any]:
sid = item.get("id")
primary_dir = str(item.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
conflict_dir = str(item.get("conflict_dir") or "").strip("/")
if conflict_dir:
# 同名冲突:报错并引导改用 read_file 按具体路径查看
return {
"success": False,
"error": tr(
"tools_read.skill_name_conflict",
sid=sid,
primary_dir=primary_dir,
conflict_dir=conflict_dir,
),
}
return {"success": True, "skill_id": sid, "display_dir": primary_dir}
# 1) 优先按 skill id 精确匹配(忽略大小写)
id_map = {str(item.get("id", "")).lower(): item for item in filtered_catalog if item.get("id")}
if normalized_lower in id_map:
return _resolve_item(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 _resolve_item(id_map[label_matches[0].lower()])
if len(label_matches) > 1:
return {
"success": False,
"error": tr("tools_read.skill_name_ambiguous", matches=', '.join(sorted(label_matches)))
}
return {"success": False, "error": tr("tools_read.skill_not_found", name=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"]
display_dir = str(resolved.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
read_args = {
"path": f"{display_dir}/{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": tr("tools_read.memory_name_invalid", name=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": tr("tools_read.search_memory_needs_keywords")}
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 = tr("tools_read.memory_dir_not_exists")
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 = tr(
"tools_read.search_no_match_content",
keywords="".join(clean_keywords),
)
return {
"success": True,
"count": 0,
"keywords": clean_keywords,
"results": [],
"content": empty_text,
"summary": tr("tools_read.search_no_match_summary"),
}
content_lines = [
tr(
"tools_read.search_found_header",
count=len(top),
keywords="".join(clean_keywords),
),
"",
]
for rank, item in enumerate(top, start=1):
content_lines.append(tr("tools_read.search_result_item", rank=rank, name=item['name'], file=item['file']))
if item["description"]:
content_lines.append(tr("tools_read.search_result_desc", description=item['description']))
if item["snippets"]:
content_lines.append(tr("tools_read.search_result_snippets_label"))
for snippet in item["snippets"]:
content_lines.append(f" L{snippet['line']}: {snippet['text']}")
content_lines.append("")
content_lines.append(tr("tools_read.search_read_full_hint"))
content_text = "\n".join(content_lines).strip()
return {
"success": True,
"count": len(top),
"keywords": clean_keywords,
"results": top,
"content": content_text,
"summary": tr("tools_read.search_found_summary", count=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, tr("tools_read.param_must_be_int", field_name=field_name)
if number < 1:
return None, tr("tools_read.param_must_be_gte_1", field_name=field_name)
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": tr("tools_read.missing_file_path")}
read_type = (arguments.get("type") or "read").lower()
if read_type not in {"read", "search", "extract"}:
return {"success": False, "error": tr("tools_read.unknown_read_type", read_type=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": tr("tools_read.end_line_ge_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": tr(
"tools_read.read_success",
path=read_result["path"],
line_start=read_result["line_start"],
line_end=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": tr("tools_read.search_requires_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": tr(
"tools_read.search_success",
path=search_result["path"],
query=query,
count=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": tr("tools_read.extract_requires_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": tr(
"tools_read.extract_success",
path=extract_result["path"],
count=len(limited_segments),
)
})
base_result["truncated"] = truncated
self.context_manager.load_file(extract_result["path"])
return base_result