diff --git a/backend/package/yuxi/agents/buildin/reporter/__init__.py b/backend/package/yuxi/agents/buildin/reporter/__init__.py deleted file mode 100644 index 374d244a..00000000 --- a/backend/package/yuxi/agents/buildin/reporter/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .graph import SqlReporterAgent - -__all__ = ["SqlReporterAgent"] diff --git a/backend/package/yuxi/agents/buildin/reporter/graph.py b/backend/package/yuxi/agents/buildin/reporter/graph.py deleted file mode 100644 index fa670e99..00000000 --- a/backend/package/yuxi/agents/buildin/reporter/graph.py +++ /dev/null @@ -1,91 +0,0 @@ -from dataclasses import dataclass, field -from typing import Annotated - -from deepagents.backends import StateBackend -from deepagents.middleware.filesystem import FilesystemMiddleware -from langchain.agents import create_agent - -from yuxi.agents import BaseAgent, BaseContext, load_chat_model -from yuxi.agents.middlewares import ( - RuntimeConfigMiddleware, - save_attachments_to_fs, -) -from yuxi.agents.toolkits.mysql import get_mysql_tools -from yuxi.services.mcp_service import get_mcp_server_names, get_tools_from_all_servers -from yuxi.utils import logger - - -def _create_fs_backend(rt): - """创建文件存储后端""" - return StateBackend(rt) - - -PROMPT = """你的任务是根据用户的指令,使用数据库工具和图表绘制工具,构建 SQL 查询报告。 -你需要根据用户的指令,生成相应的 SQL 查询,并将查询结果以报表的形式返回给用户。 -在生成报表时,你可以调用工具生成图表,以更直观地展示数据。 -务必确保生成的 SQL 查询是正确且高效的,以避免对数据库造成不必要的负担。 -在生成报表时,请遵循以下原则: -1. 理解用户的指令,明确报表的需求和目标。 -2. 图表生成工具的返回结果不会默认渲染,需要在最终的报表中以图片形式(markdown格式)嵌入。 -3. 必要时,使用网络检索相关工具补充信息。 -""" - - -@dataclass(kw_only=True) -class ReporterContext(BaseContext): - """覆盖 BaseContext,定义数据库报表助手智能体的可配置参数""" - - # 覆盖 system_prompt,提供更具体的默认值 - system_prompt: Annotated[str, {"__template_metadata__": {"kind": "prompt"}}] = field( - default=PROMPT, - metadata={"name": "系统提示词", "description": "用来描述智能体的角色和行为"}, - ) - - mcps: Annotated[list[str], {"__template_metadata__": {"kind": "mcps"}}] = field( - default_factory=lambda: ["mcp-server-chart"], - metadata={ - "name": "MCP服务器", - "options": lambda: get_mcp_server_names(), - "description": ( - "MCP服务器列表,建议使用支持 SSE 的 MCP 服务器," - "如果需要使用 uvx 或 npx 运行的服务器,也请在项目外部启动 MCP 服务器,并在项目中配置 MCP 服务器。" - ), - }, - ) - - -class SqlReporterAgent(BaseAgent): - name = "数据库报表助手" - description = ( - "一个能够生成 SQL 查询报告的智能体助手。同时调用 Charts MCP 生成图表。" - "MySQL 工具默认启用,无法选择,mcp 默认启用 Charts MCPs。" - ) - context_schema = ReporterContext - capabilities = [ - "file_upload", - "files", - ] - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - async def get_graph(self, context=None, **kwargs): - - context = context or self.context_schema() # 获取上下文配置 - - all_mcp_tools = await get_tools_from_all_servers() - - graph = create_agent( - model=load_chat_model(context.model), - system_prompt=context.system_prompt, - tools=get_mysql_tools(), # MySQL 工具默认启用,这里添加的 tools,不会在工具选择框中出现 - middleware=[ - FilesystemMiddleware(backend=_create_fs_backend), # 文件系统后端 - RuntimeConfigMiddleware(extra_tools=all_mcp_tools), - save_attachments_to_fs, # 附件保存到文件系统 - ], - checkpointer=await self._get_checkpointer(), - ) - - logger.info("SqlReporterAgent 构建成功") - return graph diff --git a/backend/package/yuxi/agents/skills/buildin/__init__.py b/backend/package/yuxi/agents/skills/buildin/__init__.py new file mode 100644 index 00000000..87db78c6 --- /dev/null +++ b/backend/package/yuxi/agents/skills/buildin/__init__.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from yuxi.agents.toolkits.utils import get_tool_info +from yuxi.agents.toolkits.mysql import get_mysql_tools + + +@dataclass(frozen=True) +class BuiltinSkillSpec: + slug: str + source_dir: Path + description: str = "" + tool_dependencies: tuple[str, ...] = () + mcp_dependencies: tuple[str, ...] = () + skill_dependencies: tuple[str, ...] = () + + +_SKILLS_ROOT = Path(__file__).resolve().parent + +BUILTIN_SKILLS: list[BuiltinSkillSpec] = [ + BuiltinSkillSpec( + slug="reporter", + source_dir=_SKILLS_ROOT / "reporter", + description="生成 SQL 查询报表并生成可视化图表。", + tool_dependencies=[t["name"] for t in get_tool_info(get_mysql_tools())], + mcp_dependencies=("mcp-server-chart",), + ), +] + diff --git a/backend/package/yuxi/agents/skills/reporter/SKILLS.md b/backend/package/yuxi/agents/skills/buildin/reporter/SKILL.md similarity index 98% rename from backend/package/yuxi/agents/skills/reporter/SKILLS.md rename to backend/package/yuxi/agents/skills/buildin/reporter/SKILL.md index 4846cd83..9606faa1 100644 --- a/backend/package/yuxi/agents/skills/reporter/SKILLS.md +++ b/backend/package/yuxi/agents/skills/buildin/reporter/SKILL.md @@ -1,5 +1,5 @@ --- -name: sql-reporter +name: reporter description: "生成 SQL 查询报表并生成可视化图表。当用户需要查询数据库并以报表形式展示结果时使用此技能,包括:统计销售数据、分析用户行为、生成业务报表、查询业务指标等。" --- diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py index cb8c9bb1..5a5ea0d6 100644 --- a/backend/package/yuxi/services/skill_service.py +++ b/backend/package/yuxi/services/skill_service.py @@ -15,6 +15,7 @@ from yuxi import config as sys_config from yuxi.repositories.skill_repository import SkillRepository from yuxi.services.mcp_service import get_mcp_server_names from yuxi.storage.postgres.models_business import Skill +from yuxi.utils.logging_config import logger SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") SKILL_NAME_PATTERN = SKILL_SLUG_PATTERN @@ -50,6 +51,8 @@ TEXT_FILE_EXTENSIONS = { ".tsx", } +BUILTIN_SKILL_OPERATOR = "builtin-system" + def _normalize_string_list(values: list[str] | None) -> list[str]: if not values: @@ -79,6 +82,37 @@ def get_skills_root_dir() -> Path: return root +def get_builtin_skill_specs() -> list[Any]: + from yuxi.agents.skills.buildin import BUILTIN_SKILLS + + return BUILTIN_SKILLS + + +def _build_builtin_skill_dir_path(slug: str) -> str: + return (Path("skills") / slug).as_posix() + + +def _is_builtin_managed(item: Skill, slug: str) -> bool: + expected_dir = _build_builtin_skill_dir_path(slug) + if item.dir_path != expected_dir: + return False + return (item.created_by or "") in {"system", BUILTIN_SKILL_OPERATOR} + + +def _ensure_symlink_target(target_dir: Path, source_dir: Path) -> None: + if target_dir.is_symlink(): + if target_dir.resolve() == source_dir.resolve(): + return + target_dir.unlink() + elif target_dir.exists(): + raise ValueError(f"技能目录已存在且非内置链接,无法托管: {target_dir}") + + try: + target_dir.symlink_to(source_dir, target_is_directory=True) + except OSError as e: + raise ValueError(f"创建内置技能链接失败: {target_dir} -> {source_dir} ({e})") from e + + async def get_skill_dependency_options(db: AsyncSession) -> dict[str, list[str] | list[dict]]: # 并行执行三个独立操作 from yuxi.services.tool_service import get_tool_metadata @@ -541,3 +575,80 @@ async def delete_skill(db: AsyncSession, *, slug: str) -> None: if trash_dir and trash_dir.exists(): shutil.rmtree(trash_dir, ignore_errors=True) + + +async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -> None: + """初始化内置 skills(软链接目录 + 启动时同步元数据)。""" + repo = SkillRepository(db) + skills_root = get_skills_root_dir() + specs = get_builtin_skill_specs() + + for spec in specs: + slug = str(getattr(spec, "slug", "")).strip() + source_dir = Path(str(getattr(spec, "source_dir", ""))).resolve() + configured_description = str(getattr(spec, "description", "")).strip() + configured_tools = _normalize_string_list(getattr(spec, "tool_dependencies", None)) + configured_mcps = _normalize_string_list(getattr(spec, "mcp_dependencies", None)) + configured_skills = _normalize_string_list(getattr(spec, "skill_dependencies", None)) + + if not is_valid_skill_slug(slug): + raise ValueError(f"内置 skill slug 非法: {slug}") + if not source_dir.exists() or not source_dir.is_dir(): + logger.warning(f"跳过不存在的内置 skill 目录: {source_dir}") + continue + + skill_md = source_dir / "SKILL.md" + if not skill_md.exists(): + legacy_skill_md = source_dir / "SKILLS.md" + if not legacy_skill_md.exists(): + raise ValueError(f"内置 skill 缺少 SKILL.md: {source_dir}") + skill_md = legacy_skill_md + + content = skill_md.read_text(encoding="utf-8") + parsed_name, parsed_desc, meta = _parse_skill_markdown(content) + if parsed_name != slug: + raise ValueError(f"内置 skill frontmatter.name 必须等于 slug: {slug}") + + description = configured_description or parsed_desc + tool_dependencies = configured_tools or _normalize_string_list(meta.get("tool_dependencies")) + mcp_dependencies = configured_mcps or _normalize_string_list(meta.get("mcp_dependencies")) + skill_dependencies = configured_skills or _normalize_string_list(meta.get("skill_dependencies")) + + target_dir = skills_root / slug + _ensure_symlink_target(target_dir, source_dir) + + item = await repo.get_by_slug(slug) + if item and not _is_builtin_managed(item, slug): + logger.warning(f"发现同名非内置 skill,跳过自动同步: {slug}") + continue + + expected_dir_path = _build_builtin_skill_dir_path(slug) + + if not item: + await repo.create( + slug=slug, + name=slug, + description=description, + tool_dependencies=tool_dependencies, + mcp_dependencies=mcp_dependencies, + skill_dependencies=skill_dependencies, + dir_path=expected_dir_path, + created_by=BUILTIN_SKILL_OPERATOR, + ) + continue + + if item.name != slug or item.description != description: + await repo.update_metadata(item, name=slug, description=description, updated_by=created_by) + + if ( + _normalize_string_list(item.tool_dependencies or []) != tool_dependencies + or _normalize_string_list(item.mcp_dependencies or []) != mcp_dependencies + or _normalize_string_list(item.skill_dependencies or []) != skill_dependencies + ): + await repo.update_dependencies( + item, + tool_dependencies=tool_dependencies, + mcp_dependencies=mcp_dependencies, + skill_dependencies=skill_dependencies, + updated_by=created_by, + ) diff --git a/backend/server/utils/lifespan.py b/backend/server/utils/lifespan.py index 92ace27a..dea357f2 100644 --- a/backend/server/utils/lifespan.py +++ b/backend/server/utils/lifespan.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from yuxi.services.task_service import tasker from yuxi.services.mcp_service import init_mcp_servers +from yuxi.services.skill_service import init_builtin_skills from yuxi.services.subagent_service import init_builtin_subagents from yuxi.services.run_queue_service import close_queue_clients, get_redis_client from yuxi.storage.postgres.manager import pg_manager @@ -37,6 +38,14 @@ async def lifespan(app: FastAPI): logger.error(f"Failed to initialize builtin subagents during startup: {e}") raise + # 初始化内置 Skills + try: + async with pg_manager.get_async_session_context() as session: + await init_builtin_skills(session) + except Exception as e: + logger.error(f"Failed to initialize builtin skills during startup: {e}") + raise + # 初始化知识库管理器 import os if os.environ.get("LITE_MODE", "").lower() in ("true", "1"): diff --git a/backend/test/test_skill_service.py b/backend/test/test_skill_service.py index 0498c665..f66c9cc6 100644 --- a/backend/test/test_skill_service.py +++ b/backend/test/test_skill_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import io import zipfile from pathlib import Path +from types import SimpleNamespace import pytest @@ -280,3 +281,197 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch): assert captured["skill_dependencies"] == ["beta"] assert captured["updated_by"] == "root" assert updated.skill_dependencies == ["beta"] + + +@pytest.mark.asyncio +async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + source_dir = tmp_path / "builtin-skills" / "reporter" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "SKILL.md").write_text( + "---\n" + "name: reporter\n" + "description: SQL report\n" + "---\n" + "# SQL Reporter\n", + encoding="utf-8", + ) + (source_dir / "prompts").mkdir(parents=True, exist_ok=True) + (source_dir / "prompts" / "system.md").write_text("prompt", encoding="utf-8") + + monkeypatch.setattr( + svc, + "get_builtin_skill_specs", + lambda: [ + SimpleNamespace( + slug="reporter", + source_dir=source_dir, + description="SQL report from python", + tool_dependencies=("mysql_query",), + mcp_dependencies=("charts",), + skill_dependencies=("common-report",), + ) + ], + ) + + class FakeRepo: + created: list[dict] = [] + + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + return None + + async def create( + self, + *, + slug: str, + name: str, + description: str, + tool_dependencies: list[str] | None, + mcp_dependencies: list[str] | None, + skill_dependencies: list[str] | None, + dir_path: str, + created_by: str | None, + ) -> Skill: + self.__class__.created.append( + { + "slug": slug, + "name": name, + "description": description, + "tool_dependencies": tool_dependencies, + "mcp_dependencies": mcp_dependencies, + "skill_dependencies": skill_dependencies, + "dir_path": dir_path, + "created_by": created_by, + } + ) + return Skill( + slug=slug, + name=name, + description=description, + dir_path=dir_path, + tool_dependencies=tool_dependencies or [], + mcp_dependencies=mcp_dependencies or [], + skill_dependencies=skill_dependencies or [], + created_by=created_by, + updated_by=created_by, + ) + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + await svc.init_builtin_skills(None) + + assert len(FakeRepo.created) == 1 + created = FakeRepo.created[0] + assert created["slug"] == "reporter" + assert created["name"] == "reporter" + assert created["description"] == "SQL report from python" + assert created["tool_dependencies"] == ["mysql_query"] + assert created["mcp_dependencies"] == ["charts"] + assert created["skill_dependencies"] == ["common-report"] + assert created["created_by"] == svc.BUILTIN_SKILL_OPERATOR + + target_dir = tmp_path / "skills" / "reporter" + assert target_dir.is_symlink() + assert (target_dir / "SKILL.md").exists() + + +@pytest.mark.asyncio +async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + source_dir = tmp_path / "builtin-skills" / "reporter" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "SKILL.md").write_text( + "---\n" + "name: reporter\n" + "description: old\n" + "---\n" + "# SQL Reporter\n", + encoding="utf-8", + ) + + monkeypatch.setattr( + svc, + "get_builtin_skill_specs", + lambda: [ + SimpleNamespace( + slug="reporter", + source_dir=source_dir, + description="new description", + tool_dependencies=("mysql_query",), + mcp_dependencies=("charts",), + skill_dependencies=(), + ) + ], + ) + + existing_item = Skill( + slug="reporter", + name="reporter", + description="old description", + dir_path="skills/reporter", + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=[], + created_by="system", + updated_by="system", + ) + + captured: dict[str, list[str] | str | None] = {} + + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + return existing_item + + async def update_metadata( + self, + item: Skill, + *, + name: str, + description: str, + updated_by: str | None, + ) -> Skill: + item.name = name + item.description = description + captured["name"] = name + captured["description"] = description + captured["updated_by"] = updated_by + return item + + async def update_dependencies( + self, + item: Skill, + *, + tool_dependencies: list[str], + mcp_dependencies: list[str], + skill_dependencies: list[str], + updated_by: str | None, + ) -> Skill: + item.tool_dependencies = tool_dependencies + item.mcp_dependencies = mcp_dependencies + item.skill_dependencies = skill_dependencies + captured["tool_dependencies"] = tool_dependencies + captured["mcp_dependencies"] = mcp_dependencies + captured["skill_dependencies"] = skill_dependencies + captured["updated_by_deps"] = updated_by + return item + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + await svc.init_builtin_skills(None, created_by="release-bot") + + target_dir = tmp_path / "skills" / "reporter" + assert target_dir.is_symlink() + assert captured["description"] == "new description" + assert captured["tool_dependencies"] == ["mysql_query"] + assert captured["mcp_dependencies"] == ["charts"] + assert captured["skill_dependencies"] == [] + assert captured["updated_by"] == "release-bot" + assert captured["updated_by_deps"] == "release-bot"