feat: 添加内置技能支持,包括 reporter 技能的实现和初始化逻辑,移除内置的 reporter 的 Agent

This commit is contained in:
Wenjie Zhang 2026-03-21 16:04:18 +08:00
parent 73bb5ae6b9
commit d06c7000ea
7 changed files with 347 additions and 95 deletions

View File

@ -1,3 +0,0 @@
from .graph import SqlReporterAgent
__all__ = ["SqlReporterAgent"]

View File

@ -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

View File

@ -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",),
),
]

View File

@ -1,5 +1,5 @@
---
name: sql-reporter
name: reporter
description: "生成 SQL 查询报表并生成可视化图表。当用户需要查询数据库并以报表形式展示结果时使用此技能,包括:统计销售数据、分析用户行为、生成业务报表、查询业务指标等。"
---

View File

@ -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,
)

View File

@ -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"):

View File

@ -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"