diff --git a/config/paths.py b/config/paths.py index c35bef13..ad104204 100644 --- a/config/paths.py +++ b/config/paths.py @@ -168,6 +168,8 @@ CUSTOM_ROLES_DIR = str(Path(RUNTIME_ROOT) / _MODE / "mutiagents" / "agents") # web 模式预设角色目录(web 模式下使用,host 模式下不用) WEB_PRESET_ROLES_DIR = str(Path(RUNTIME_ROOT) / "web" / "mutiagents" / "agents") WORKSPACE_SKILLS_DIRNAME = ".astrion/skills" +# 行业通用技能目录(Agent Skills 开放标准,工作区根目录下,扫描不同步复制) +PROJECT_AGENTS_SKILLS_DIRNAME = ".agents/skills" WORKSPACE_MEMORY_DIRNAME = ".astrion/memory" WORKSPACE_REVIEW_DIRNAME = ".astrion/review" HOST_WORKSPACES_FILE = _resolve_repo_path( @@ -193,6 +195,7 @@ __all__ = [ "LOGS_DIR", "AGENT_SKILLS_DIR", "WORKSPACE_SKILLS_DIRNAME", + "PROJECT_AGENTS_SKILLS_DIRNAME", "WORKSPACE_MEMORY_DIRNAME", "WORKSPACE_REVIEW_DIRNAME", "IS_HOST_MODE", diff --git a/core/main_terminal_parts/context/messages.py b/core/main_terminal_parts/context/messages.py index 0ff76a21..ece0b740 100644 --- a/core/main_terminal_parts/context/messages.py +++ b/core/main_terminal_parts/context/messages.py @@ -88,18 +88,18 @@ logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True -def _build_sub_agents_md_notice(sub_paths: List[str], total: int) -> str: - """构建子目录 AGENTS.md 路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。""" +def _build_sub_md_notice(filename: str, sub_paths: List[str], total: int) -> str: + """构建子目录指令文件路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。""" if not sub_paths: return "" - lines = ["另外,工作区以下子目录也存在 AGENTS.md 规范文件:", ""] + lines = [f"另外,工作区以下子目录也存在 {filename} 规范文件:", ""] for p in sub_paths: lines.append(f"- `{p}`") if total > len(sub_paths): lines.append("") lines.append(f"...共 {total} 个") lines.append("") - lines.append("这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 AGENTS.md 了解局部规范后再动手,不得将子目录规范当作全局规范套用。") + lines.append(f"这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 {filename} 了解局部规范后再动手,不得将子目录规范当作全局规范套用。") return "\n".join(lines) @@ -181,7 +181,7 @@ class MessagesMixin: personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir) shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False - # 顺序:主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → skills → 记忆 → 自定义 → 禁用提示 + # 顺序:主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → CLAUDE.md → skills → 记忆 → 自定义 → 禁用提示 # 权限模式 permission_mode_message = self._get_or_init_frozen_mode_prompt( @@ -275,7 +275,7 @@ class MessagesMixin: return "" # 子目录 AGENTS.md 只通知相对路径,不注入内容 sub_paths, sub_total = self._load_sub_agents_md_paths() - sub_notice = _build_sub_agents_md_notice(sub_paths, sub_total) + sub_notice = _build_sub_md_notice("AGENTS.md", sub_paths, sub_total) agents_md_template = self.load_prompt("agents_md_inject").strip() if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template: text = agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content) @@ -300,8 +300,48 @@ class MessagesMixin: if agents_md_text: messages.append({"role": "system", "content": agents_md_text}) + # CLAUDE.md(默认关闭,开启后与 AGENTS.md 并列注入) + def _build_claude_md_prompt() -> str: + claude_md_content = self._load_root_md_content("CLAUDE.md") + if not claude_md_content: + return "" + # 子目录 CLAUDE.md 只通知相对路径,不注入内容 + sub_paths, sub_total = self._load_sub_md_paths("CLAUDE.md") + sub_notice = _build_sub_md_notice("CLAUDE.md", sub_paths, sub_total) + claude_md_template = self.load_prompt("claude_md_inject").strip() + if claude_md_template and "{{CLAUDE_MD_CONTENT}}" in claude_md_template: + text = claude_md_template.replace("{{CLAUDE_MD_CONTENT}}", claude_md_content) + text = text.replace("{{CLAUDE_MD_UPDATED_AT}}", self._load_root_md_updated_at("CLAUDE.md")) + text = text.replace("{{SUB_CLAUDE_MD_NOTICE}}", sub_notice) + return text + # 模板缺失/无占位符时的兜底格式 + text = f"【CLAUDE.md 项目规范】{self._load_root_md_updated_at('CLAUDE.md')}\n\n{claude_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若有冲突请以 CLAUDE.md 为准。" + return f"{text}\n\n{sub_notice}" if sub_notice else text + + # 与 AGENTS.md 同理,只在开启时生成并缓存 + claude_md_inject_enabled = ( + bool(personalization_config.get("claude_md_auto_inject", False)) + if isinstance(personalization_config, dict) + else False + ) + if claude_md_inject_enabled: + claude_md_text = self._get_or_init_frozen_prompt( + "frozen_claude_md_prompt", + _build_claude_md_prompt, + ) + if claude_md_text: + messages.append({"role": "system", "content": claude_md_text}) + # skills 列表 - skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) + skills_catalog = get_skills_catalog( + private_dir=infer_private_skills_dir(self.data_dir), + project_path=self.project_path, + scan_project_agents=( + bool(personalization_config.get("agents_skills_scan_enabled", True)) + if isinstance(personalization_config, dict) + else True + ), + ) enabled_skills = merge_enabled_skills( personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None, skills_catalog, diff --git a/core/main_terminal_parts/context/prompt.py b/core/main_terminal_parts/context/prompt.py index 095460c5..a0f61a93 100644 --- a/core/main_terminal_parts/context/prompt.py +++ b/core/main_terminal_parts/context/prompt.py @@ -101,23 +101,23 @@ _AGENTS_MD_SKIP_DIRS = { class PromptMixin: """MainTerminalContextMixin prompt 能力 mixin。""" - def _load_agents_md_content(self) -> Optional[str]: - """加载工作区根目录的 AGENTS.md 文件内容(仅根目录,子目录的不注入)。""" + def _load_root_md_content(self, filename: str) -> Optional[str]: + """加载工作区根目录的指定指令文件内容(仅根目录,子目录的不注入)。""" try: project_path = Path(self.project_path) - root_file = project_path / "AGENTS.md" + root_file = project_path / filename if not root_file.is_file(): return None content = root_file.read_text(encoding='utf-8') return content.strip() if content else None except Exception as exc: - logger.warning(f"[AGENTS.md] 读取失败: {exc}") + logger.warning(f"[{filename}] 读取失败: {exc}") return None - def _load_agents_md_updated_at(self) -> str: - """返回根目录 AGENTS.md 的最后修改时间文案(如「(最后修改:2026-08-12 17:54)」,失败返回空串)。""" + def _load_root_md_updated_at(self, filename: str) -> str: + """返回根目录指定指令文件的最后修改时间文案(如「(最后修改:2026-08-12 17:54)」,失败返回空串)。""" try: - root_file = Path(self.project_path) / "AGENTS.md" + root_file = Path(self.project_path) / filename if not root_file.is_file(): return "" mtime = root_file.stat().st_mtime @@ -125,8 +125,8 @@ class PromptMixin: except Exception: return "" - def _load_sub_agents_md_paths(self, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple: - """扫描工作区一级子目录中的 AGENTS.md,返回 (相对路径列表, 实际总数)。 + def _load_sub_md_paths(self, filename: str, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple: + """扫描工作区一级子目录中的指定指令文件,返回 (相对路径列表, 实际总数)。 - 仅扫描根目录与一级子目录,不递归;跳过通用非源码目录(_AGENTS_MD_SKIP_DIRS)。 - 列表最多 max_notice 个(超出截断,总数仍在第二项返回)。 @@ -141,19 +141,31 @@ class PromptMixin: start = time.monotonic() for sub in project_path.iterdir(): if time.monotonic() - start >= timeout_seconds: - logger.warning(f"[AGENTS.md] 子目录扫描超过 {timeout_seconds}s,返回已扫描到的 {len(found)} 个") + logger.warning(f"[{filename}] 子目录扫描超过 {timeout_seconds}s,返回已扫描到的 {len(found)} 个") break if not sub.is_dir() or sub.name in _AGENTS_MD_SKIP_DIRS: continue - candidate = sub / "AGENTS.md" + candidate = sub / filename if candidate.is_file(): total += 1 if len(found) < max_notice: found.append(candidate.relative_to(project_path).as_posix()) except Exception as exc: - logger.warning(f"[AGENTS.md] 子目录扫描失败: {exc}") + logger.warning(f"[{filename}] 子目录扫描失败: {exc}") return found, total + def _load_agents_md_content(self) -> Optional[str]: + """加载工作区根目录的 AGENTS.md 文件内容(仅根目录,子目录的不注入)。""" + return self._load_root_md_content("AGENTS.md") + + def _load_agents_md_updated_at(self) -> str: + """返回根目录 AGENTS.md 的最后修改时间文案(如「(最后修改:2026-08-12 17:54)」,失败返回空串)。""" + return self._load_root_md_updated_at("AGENTS.md") + + def _load_sub_agents_md_paths(self, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple: + """扫描工作区一级子目录中的 AGENTS.md,返回 (相对路径列表, 实际总数)。""" + return self._load_sub_md_paths("AGENTS.md", max_notice=max_notice, timeout_seconds=timeout_seconds) + def load_prompt(self, name: str) -> str: """加载提示模板""" prompt_file = Path(PROMPTS_DIR) / f"{name}.txt" diff --git a/core/main_terminal_parts/tools_definition/file_tools.py b/core/main_terminal_parts/tools_definition/file_tools.py index 800bb174..62ea7b81 100644 --- a/core/main_terminal_parts/tools_definition/file_tools.py +++ b/core/main_terminal_parts/tools_definition/file_tools.py @@ -119,7 +119,7 @@ class ToolsDefinitionFileToolsMixin: "type": "function", "function": { "name": "read_skill", - "description": "按 skill 名称读取 .astrion/skills//SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。", + "description": "按 skill 名称读取 SKILL.md 内容(.astrion/skills// 或 .agents/skills//);内部等价于 read_file 的 read 模式,并返回解析后的 path。若技能在 .astrion/skills/ 与 .agents/skills/ 同名重复,会报错并提示改用 read_file 按具体路径读取。", "parameters": { "type": "object", "properties": self._inject_intent({ diff --git a/core/main_terminal_parts/tools_read.py b/core/main_terminal_parts/tools_read.py index 98350d1e..a2024607 100644 --- a/core/main_terminal_parts/tools_read.py +++ b/core/main_terminal_parts/tools_read.py @@ -21,6 +21,7 @@ try: CUSTOM_TOOLS_ENABLED, WORKSPACE_SKILLS_DIRNAME, WORKSPACE_MEMORY_DIRNAME, + PROJECT_AGENTS_SKILLS_DIRNAME, ) except ImportError: import sys @@ -43,6 +44,7 @@ except ImportError: CUSTOM_TOOLS_ENABLED, WORKSPACE_SKILLS_DIRNAME, WORKSPACE_MEMORY_DIRNAME, + PROJECT_AGENTS_SKILLS_DIRNAME, ) from modules.file_manager import FileManager @@ -92,9 +94,10 @@ class MainTerminalToolsReadMixin: if not raw: return "" normalized = raw.replace("\\", "/").strip("/") - prefix = f"{WORKSPACE_SKILLS_DIRNAME}/" - if normalized.lower().startswith(prefix): - normalized = normalized[len(prefix):] + 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() @@ -108,7 +111,16 @@ class MainTerminalToolsReadMixin: personalization = load_personalization_config(self.data_dir) except Exception: personalization = {} - catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) + 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, @@ -119,10 +131,28 @@ class MainTerminalToolsReadMixin: 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": ( + f"技能 '{sid}' 存在同名重复:{primary_dir}/{sid}/SKILL.md 与 " + f"{conflict_dir}/{sid}/SKILL.md 均存在,read_skill 无法确定读取哪一份。\n" + f"请改用 read_file 工具按具体路径读取查看(可分别读取 " + f"{primary_dir}/{sid}/SKILL.md 与 {conflict_dir}/{sid}/SKILL.md 对比)," + f"或告知用户删除/重命名其中一份以消除重复。" + ), + } + return {"success": True, "skill_id": sid, "display_dir": primary_dir} + # 1) 优先按 skill id 精确匹配(忽略大小写) - id_map = {str(item.get("id", "")).lower(): item.get("id") for item in filtered_catalog if item.get("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 {"success": True, "skill_id": id_map[normalized_lower]} + return _resolve_item(id_map[normalized_lower]) # 2) 再按 label 匹配(忽略大小写) label_matches: List[str] = [] @@ -131,7 +161,7 @@ class MainTerminalToolsReadMixin: 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]} + return _resolve_item(id_map[label_matches[0].lower()]) if len(label_matches) > 1: return { "success": False, @@ -147,8 +177,9 @@ class MainTerminalToolsReadMixin: return resolved skill_id = resolved["skill_id"] + display_dir = str(resolved.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/") read_args = { - "path": f"{WORKSPACE_SKILLS_DIRNAME}/{skill_id}/SKILL.md", + "path": f"{display_dir}/{skill_id}/SKILL.md", "type": "read", } result = self._handle_read_tool(read_args) diff --git a/modules/multi_agent/sub_agent_context.py b/modules/multi_agent/sub_agent_context.py index 2c30dcb1..417775bd 100644 --- a/modules/multi_agent/sub_agent_context.py +++ b/modules/multi_agent/sub_agent_context.py @@ -131,27 +131,75 @@ def _build_agents_md_section(workspace_path: str) -> str: ) +def _build_claude_md_section(workspace_path: str, data_dir: str = "") -> str: + """构建 CLAUDE.md 项目规范段(遵循 claude_md_auto_inject 开关,默认关闭)。""" + try: + from modules.personalization_manager import load_personalization_config + + try: + personalization = load_personalization_config(data_dir) if data_dir else {} + except Exception: + personalization = {} + enabled = ( + bool(personalization.get("claude_md_auto_inject", False)) + if isinstance(personalization, dict) + else False + ) + if not enabled: + return "" + claude_file = Path(workspace_path) / "CLAUDE.md" + if not claude_file.is_file(): + return "" + content = claude_file.read_text(encoding="utf-8").strip() + if not content: + return "" + return ( + f"## CLAUDE.md 项目规范\n\n" + f"> 以下是工作区根目录的 CLAUDE.md 文件内容,请在回答时参考这些项目规范:\n\n" + f"{content}\n\n" + f"---\n" + f"注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若与未来代码冲突,以实际代码为准。\n" + ) + except Exception: + return "" + + def _build_skills_section(workspace_path: str, data_dir: str = "") -> str: """构建可用 skill 列表段。""" try: + from modules.personalization_manager import load_personalization_config from modules.skills_manager import ( get_skills_catalog, build_skills_list, + resolve_enabled_skills, infer_private_skills_dir, ) private_dir = infer_private_skills_dir(data_dir) if data_dir else None - catalog = get_skills_catalog(private_dir=private_dir) + try: + personalization = load_personalization_config(data_dir) if data_dir else {} + 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=private_dir, + project_path=workspace_path, + scan_project_agents=scan_project_agents, + ) # 子智能体不区分 enabled/disabled,列出全部可用 skill - skills_list = build_skills_list(catalog, enabled=None) + skills_list = build_skills_list(catalog, resolve_enabled_skills(None, catalog)) if not skills_list: return "" return ( - f"## 可用 AgentSkill\n\n" - f"agent skills 系统已启用,以下是可用的 skills(含简要说明):\n\n" - f"{skills_list}\n\n" - f"使用技能时,优先用 read_skill 通过技能名读取 SKILL.md;" - f"需要阅读 skill 目录中的其他文件时再使用 read_file\n" + "## 可用 AgentSkill\n\n" + "agent skills 系统已启用,以下是可用的 skills(含简要说明):\n\n" + + "\n".join(skills_list) + + "\n\n使用技能时,优先用 read_skill 通过技能名读取 SKILL.md;" + "需要阅读 skill 目录中的其他文件时再使用 read_file\n" ) except Exception: return "" @@ -234,6 +282,11 @@ def build_sub_agent_dynamic_context( if agents_md: sections.append(agents_md) + # 4.5 CLAUDE.md(遵循 claude_md_auto_inject 开关,默认关闭) + claude_md = _build_claude_md_section(workspace_path, data_dir=data_dir) + if claude_md: + sections.append(claude_md) + # 5. 可用 skill skills = _build_skills_section(workspace_path, data_dir=data_dir) if skills: diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 8c77fe68..7a9fac1c 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -112,6 +112,8 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "versioning_backup_mode": "shallow", # 文件备份方式:shallow-浅备份(只备份AI编辑的文件)/ full-完全备份(整个工作区) "versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite "agents_md_auto_inject": False, # AGENTS.md 自动注入开关 + "claude_md_auto_inject": False, # CLAUDE.md 自动注入开关(默认关闭) + "agents_skills_scan_enabled": True, # 自动扫描工作区 .agents/skills/ 行业通用技能目录(默认开启) "new_chat_button_behavior": "route", # 新建对话按钮行为:route-跳转空白新对话页 / blank-立即创建空对话 "default_hide_workspace": False, # 默认隐藏工作区 "hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列) @@ -548,6 +550,18 @@ def sanitize_personalization_payload( else: base["agents_md_auto_inject"] = bool(base.get("agents_md_auto_inject", False)) + # CLAUDE.md 自动注入开关(默认关闭) + if "claude_md_auto_inject" in data: + base["claude_md_auto_inject"] = bool(data.get("claude_md_auto_inject")) + else: + base["claude_md_auto_inject"] = bool(base.get("claude_md_auto_inject", False)) + + # .agents/skills/ 行业通用技能目录扫描开关(默认开启) + if "agents_skills_scan_enabled" in data: + base["agents_skills_scan_enabled"] = bool(data.get("agents_skills_scan_enabled")) + else: + base["agents_skills_scan_enabled"] = bool(base.get("agents_skills_scan_enabled", True)) + # 新建对话按钮行为:route-跳转空白新对话页 / blank-立即创建空对话 new_chat_behavior = data.get("new_chat_button_behavior", base.get("new_chat_button_behavior")) if isinstance(new_chat_behavior, str) and new_chat_behavior.strip().lower() in ("route", "blank"): diff --git a/modules/skills_manager.py b/modules/skills_manager.py index ae839ad7..1c44e5e6 100644 --- a/modules/skills_manager.py +++ b/modules/skills_manager.py @@ -12,7 +12,7 @@ import time from pathlib import Path from typing import Dict, List, Optional, Sequence -from config import AGENT_SKILLS_DIR, CUSTOM_SKILLS_DIR, IS_HOST_MODE, WORKSPACE_SKILLS_DIRNAME +from config import AGENT_SKILLS_DIR, CUSTOM_SKILLS_DIR, IS_HOST_MODE, PROJECT_AGENTS_SKILLS_DIRNAME, WORKSPACE_SKILLS_DIRNAME from utils.logger import setup_logger logger = setup_logger(__name__) @@ -282,8 +282,15 @@ def _scan_skills_catalog(root: Path) -> List[Dict[str, str]]: def get_skills_catalog( base_dir: Optional[str] = None, private_dir: Optional[str | Path] = None, + project_path: Optional[str | Path] = None, + scan_project_agents: bool = True, ) -> List[Dict[str, str]]: - """List available skills from global library plus optional private library.""" + """List available skills from global library plus optional private library. + + 来源标注:item["source"] = global(仓库 agentskills/)/ private(用户私有)/ agents(工作区 .agents/skills/); + item["display_dir"] 为该 skill 在工作区内的可读路径前缀(global/private 经同步复制后在 .astrion/skills/, + agents 原地读取在 .agents/skills/)。同名冲突时保留已有条目并打 conflict_dir 标记,由 read_skill 报错引导。 + """ roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()] if private_dir: private_root = Path(private_dir).expanduser().resolve() @@ -292,15 +299,43 @@ def get_skills_catalog( merged: Dict[str, Dict[str, str]] = {} order: List[str] = [] - for root in roots: + for index, root in enumerate(roots): + source = "global" if index == 0 else "private" for item in _scan_skills_catalog(root): skill_id = item.get("id") if not skill_id: continue if skill_id not in merged: order.append(skill_id) + item["source"] = source + item["display_dir"] = WORKSPACE_SKILLS_DIRNAME # Later roots (private) override metadata for same id. merged[skill_id] = item + + # 行业通用目录:工作区 .agents/skills/(Agent Skills 开放标准路径;原地读取,不参与同步复制) + if project_path and scan_project_agents: + try: + agents_root = (Path(project_path).expanduser().resolve() / PROJECT_AGENTS_SKILLS_DIRNAME).resolve() + except Exception: + agents_root = None + if agents_root and agents_root not in roots: + for item in _scan_skills_catalog(agents_root): + skill_id = item.get("id") + if not skill_id: + continue + if skill_id in merged: + # 同名冲突:保留已有(.astrion/skills)条目,仅打冲突标记; + # prompt 列表与 read_skill 据此提示用户用 read_file 按具体路径查看 + merged[skill_id]["conflict_dir"] = PROJECT_AGENTS_SKILLS_DIRNAME + logger.warning( + "[skills] 技能 %s 同时存在于 %s 与 %s,已标记同名冲突", + skill_id, WORKSPACE_SKILLS_DIRNAME, PROJECT_AGENTS_SKILLS_DIRNAME, + ) + continue + item["source"] = "agents" + item["display_dir"] = PROJECT_AGENTS_SKILLS_DIRNAME + order.append(skill_id) + merged[skill_id] = item return [merged[skill_id] for skill_id in order if skill_id in merged] @@ -353,20 +388,41 @@ def build_skills_list( catalog: Sequence[Dict[str, str]], enabled_skill_ids: Sequence[str], ) -> List[str]: - """Build skills list lines / 生成 skills 列表行。""" + """Build skills list lines / 生成 skills 列表行。 + + 列表行直接使用该 skill 在工作区内的真实路径前缀(.astrion/skills/ 或 .agents/skills/); + .agents/skills/ 来源与同名冲突会在列表尾部附说明行。 + """ if not enabled_skill_ids: return [] lookup = {item.get("id"): item for item in catalog if item.get("id")} lines: List[str] = [] + has_agents_source = False + has_conflict = False for skill_id in enabled_skill_ids: meta = lookup.get(skill_id) if not meta: continue + display_dir = (meta.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/") + if meta.get("source") == "agents": + has_agents_source = True description = (meta.get("description") or "").strip() - if description: - lines.append(f".astrion/skills/{skill_id}:{description}") - else: - lines.append(f".astrion/skills/{skill_id}") + line = f"{display_dir}/{skill_id}:{description}" if description else f"{display_dir}/{skill_id}" + conflict_dir = (meta.get("conflict_dir") or "").strip("/") + if conflict_dir: + has_conflict = True + line += f"(⚠️ 同名重复:{conflict_dir}/{skill_id} 也存在,read_skill 会报错,请改用 read_file 按具体路径读取)" + lines.append(line) + if has_agents_source: + lines.append( + f"注:以 {PROJECT_AGENTS_SKILLS_DIRNAME}/ 开头的技能来自工作区 {PROJECT_AGENTS_SKILLS_DIRNAME}/ 目录" + "(行业通用技能目录,由项目仓库提供,非 Astrion 管理),同样可用 read_skill 读取。" + ) + if has_conflict: + lines.append( + f"注:标注同名重复的技能在 {WORKSPACE_SKILLS_DIRNAME}/ 与 {PROJECT_AGENTS_SKILLS_DIRNAME}/ 各有一份," + "内容可能不同;read_skill 对重复名会报错,此时请改用 read_file 读取具体路径查看。" + ) return lines diff --git a/modules/sub_agent/toolkit.py b/modules/sub_agent/toolkit.py index 33f2a69e..82358fa4 100644 --- a/modules/sub_agent/toolkit.py +++ b/modules/sub_agent/toolkit.py @@ -163,7 +163,7 @@ SUB_AGENT_TOOLS: List[Dict[str, Any]] = [ "type": "function", "function": { "name": "read_skill", - "description": "按 skill 名称读取 .astrion/skills//SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。", + "description": "按 skill 名称读取 SKILL.md 内容(.astrion/skills// 或 .agents/skills//);内部等价于 read_file 的 read 模式,并返回解析后的 path。若技能在 .astrion/skills/ 与 .agents/skills/ 同名重复,会报错并提示改用 read_file 按具体路径读取。", "parameters": { "type": "object", "properties": { diff --git a/prompts/claude_md_inject.txt b/prompts/claude_md_inject.txt new file mode 100644 index 00000000..02e718e4 --- /dev/null +++ b/prompts/claude_md_inject.txt @@ -0,0 +1,10 @@ +【CLAUDE.md 项目规范】 + +以下是工作区根目录 CLAUDE.md 文件的内容{{CLAUDE_MD_UPDATED_AT}},请在回答时参考这些项目规范: + +{{CLAUDE_MD_CONTENT}} + +--- +注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若与未来代码冲突,以实际代码为准。 + +{{SUB_CLAUDE_MD_NOTICE}} diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index c0769ad8..7efb6054 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -931,6 +931,38 @@ }) " /> + + + +