Compare commits

...

2 Commits

Author SHA1 Message Date
aac374b6f4 fix(context): AGENTS.md 注入仅取根目录,子目录文件只通知相对路径
- _load_agents_md_content 由 rglob 递归改为只读根目录 AGENTS.md
- 新增一级子目录扫描:仅通知相对路径(最多 20 个、30s 超时兜底),不注入内容
- 跳过通用非源码目录(.git/node_modules/.venv/dist 等),不针对本项目特化
- 注入模板新增修改时间与子目录通知占位符
2026-08-13 00:39:27 +08:00
cedef87dab fix(skills): /菜单插入 skill 改用相对路径,避免暴露服务器绝对路径
- /api/skills 列表 path 从 str(skill_file.resolve()) 改为相对工作区根路径
- 注入对话历史与任务快照的 skill path 同步改为相对路径
- _resolve_workspace_skill_path 本身支持相对路径解析,前端无需改动
- 存量绝对路径仍可解析,向后兼容
2026-08-13 00:21:33 +08:00
4 changed files with 97 additions and 22 deletions

View File

@ -88,6 +88,21 @@ logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
def _build_sub_agents_md_notice(sub_paths: List[str], total: int) -> str:
"""构建子目录 AGENTS.md 路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。"""
if not sub_paths:
return ""
lines = ["另外,工作区以下子目录也存在 AGENTS.md 规范文件:", ""]
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 了解局部规范后再动手,不得将子目录规范当作全局规范套用。")
return "\n".join(lines)
class MessagesMixin:
"""MainTerminalContextMixin messages 能力 mixin。"""
@ -244,10 +259,18 @@ class MessagesMixin:
agents_md_content = self._load_agents_md_content()
if not agents_md_content:
return ""
# 子目录 AGENTS.md 只通知相对路径,不注入内容
sub_paths, sub_total = self._load_sub_agents_md_paths()
sub_notice = _build_sub_agents_md_notice(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:
return agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
return f"【AGENTS.md 项目规范】\n\n{agents_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 AGENTS.md 文件,若有冲突请以 AGENTS.md 为准。"
text = agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
text = text.replace("{{AGENTS_MD_UPDATED_AT}}", self._load_agents_md_updated_at())
text = text.replace("{{SUB_AGENTS_MD_NOTICE}}", sub_notice)
return text
# 模板缺失/无占位符时的兜底格式
text = f"【AGENTS.md 项目规范】{self._load_agents_md_updated_at()}\n\n{agents_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 AGENTS.md 文件,若有冲突请以 AGENTS.md 为准。"
return f"{text}\n\n{sub_notice}" if sub_notice else text
# AGENTS.md 注入开关可能变化,只在开启时生成并缓存,避免关闭时把空字符串冻住
agents_md_inject_enabled = (

View File

@ -1,5 +1,7 @@
import asyncio
import json
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
@ -86,35 +88,72 @@ from config.model_profiles import (
logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
# 扫描一级子目录 AGENTS.md 时跳过的通用非源码目录(版本控制/依赖/构建产物/工具数据等,不针对本项目特化)
_AGENTS_MD_SKIP_DIRS = {
".git", ".hg", ".svn",
"node_modules", "bower_components",
".venv", "venv", "__pycache__",
"dist", "build", ".next", ".nuxt", ".output",
".astrion", ".claude", ".cursor", ".idea", ".vscode",
}
class PromptMixin:
"""MainTerminalContextMixin prompt 能力 mixin。"""
def _load_agents_md_content(self) -> Optional[str]:
"""加载工作区根目录的 AGENTS.md 文件内容。
如果存在多个 AGENTS.md 文件返回最新更新的那一个
"""
"""加载工作区根目录的 AGENTS.md 文件内容(仅根目录,子目录的不注入)。"""
try:
project_path = Path(self.project_path)
if not project_path.exists():
root_file = project_path / "AGENTS.md"
if not root_file.is_file():
return None
# 查找所有 AGENTS.md 文件
agents_md_files = list(project_path.rglob("AGENTS.md"))
if not agents_md_files:
return None
# 找到最新更新的文件
latest_file = max(agents_md_files, key=lambda p: p.stat().st_mtime)
# 读取文件内容
content = latest_file.read_text(encoding='utf-8')
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}")
return None
def _load_agents_md_updated_at(self) -> str:
"""返回根目录 AGENTS.md 的最后修改时间文案如「最后修改2026-08-12 17:54失败返回空串"""
try:
root_file = Path(self.project_path) / "AGENTS.md"
if not root_file.is_file():
return ""
mtime = root_file.stat().st_mtime
return f"(最后修改:{datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M')}"
except Exception:
return ""
def _load_sub_agents_md_paths(self, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
"""扫描工作区一级子目录中的 AGENTS.md返回 (相对路径列表, 实际总数)。
- 仅扫描根目录与一级子目录不递归跳过通用非源码目录_AGENTS_MD_SKIP_DIRS
- 列表最多 max_notice 超出截断总数仍在第二项返回
- 扫描超过 timeout_seconds 秒时停止返回已扫描到的结果
"""
found: List[str] = []
total = 0
try:
project_path = Path(self.project_path)
if not project_path.is_dir():
return found, total
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)}")
break
if not sub.is_dir() or sub.name in _AGENTS_MD_SKIP_DIRS:
continue
candidate = sub / "AGENTS.md"
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}")
return found, total
def load_prompt(self, name: str) -> str:
"""加载提示模板"""
prompt_file = Path(PROMPTS_DIR) / f"{name}.txt"

View File

@ -1,8 +1,10 @@
【AGENTS.md 项目规范】
以下是工作区根目录 AGENTS.md 文件的内容,请在回答时参考这些项目规范:
以下是工作区根目录 AGENTS.md 文件的内容{{AGENTS_MD_UPDATED_AT}},请在回答时参考这些项目规范:
{{AGENTS_MD_CONTENT}}
---
注意:以上规范来自工作区根目录的 AGENTS.md 文件,若与未来代码冲突,以实际代码为准并及时更新 AGENTS.md。
{{SUB_AGENTS_MD_NOTICE}}

View File

@ -35,8 +35,19 @@ def _parse_skill_metadata(content: str, fallback_name: str) -> Dict[str, str]:
metadata["name"] = metadata.get("name") or fallback_name
return metadata
def _workspace_project_root(workspace) -> Path:
"""工作区根目录(已 resolve 的绝对路径)。"""
return Path(workspace.project_path).expanduser().resolve()
def _workspace_skills_dir(workspace) -> Path:
return (Path(workspace.project_path).expanduser().resolve() / WORKSPACE_SKILLS_DIRNAME).resolve()
return _workspace_project_root(workspace) / WORKSPACE_SKILLS_DIRNAME
def _rel_skill_path(skill_file: Path, workspace) -> str:
"""把 skill 文件的绝对路径转成相对工作区根的路径(如 `.astrion/skills/xxx/SKILL.md`)。
前端 / 菜单插入对话历史任务快照均使用该相对路径避免暴露服务器绝对路径
"""
return str(skill_file.relative_to(_workspace_project_root(workspace)).as_posix())
def _resolve_workspace_skill_path(workspace, raw_path: str) -> Path:
try:
@ -83,7 +94,7 @@ def _list_workspace_skills(workspace) -> List[Dict[str, str]]:
result.append({
"name": metadata.get("name") or skill_file.parent.name,
"description": metadata.get("description") or "",
"path": str(skill_file.resolve()),
"path": _rel_skill_path(skill_file, workspace),
})
return result
@ -102,7 +113,7 @@ def _build_skill_context_messages(workspace, raw_refs: Any) -> List[Dict[str, st
if not raw_path:
continue
skill_file = _resolve_workspace_skill_path(workspace, raw_path)
path_key = str(skill_file)
path_key = _rel_skill_path(skill_file, workspace)
if path_key in seen_paths:
debug_log(f"[SkillsAPI] build_skill_context duplicate path skipped: {path_key}")
continue