feat: 添加内置技能支持,包括 reporter 技能的实现和初始化逻辑,移除内置的 reporter 的 Agent
This commit is contained in:
parent
73bb5ae6b9
commit
d06c7000ea
@ -1,3 +0,0 @@
|
||||
from .graph import SqlReporterAgent
|
||||
|
||||
__all__ = ["SqlReporterAgent"]
|
||||
@ -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
|
||||
31
backend/package/yuxi/agents/skills/buildin/__init__.py
Normal file
31
backend/package/yuxi/agents/skills/buildin/__init__.py
Normal 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",),
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
name: sql-reporter
|
||||
name: reporter
|
||||
description: "生成 SQL 查询报表并生成可视化图表。当用户需要查询数据库并以报表形式展示结果时使用此技能,包括:统计销售数据、分析用户行为、生成业务报表、查询业务指标等。"
|
||||
---
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user