diff --git a/backend/package/yuxi/agents/buildin/subagent/graph.py b/backend/package/yuxi/agents/buildin/subagent/graph.py index a9c1ccfb..77eb5926 100644 --- a/backend/package/yuxi/agents/buildin/subagent/graph.py +++ b/backend/package/yuxi/agents/buildin/subagent/graph.py @@ -15,7 +15,7 @@ from yuxi.agents.middlewares.knowledge_base import KnowledgeBaseMiddleware from yuxi.agents.middlewares.skills import SkillsMiddleware from yuxi.agents.toolkits.service import resolve_configured_runtime_tools -_SUBAGENT_DISABLED_TOOLS = frozenset({"present_artifacts", "ask_user_question"}) +_SUBAGENT_DISABLED_TOOLS = frozenset({"present_artifacts", "ask_user_question", "install_skill"}) def _tool_name(tool) -> str | None: diff --git a/backend/package/yuxi/agents/context.py b/backend/package/yuxi/agents/context.py index 713250df..db9cf1cb 100644 --- a/backend/package/yuxi/agents/context.py +++ b/backend/package/yuxi/agents/context.py @@ -179,8 +179,8 @@ class BaseContext: metadata={ "name": "Skills", "options": [], - "description": "可选技能列表(由超级管理员维护),默认选择当前用户可用的全部 skills。" - "技能依赖的工具和 MCP 服务器也会被自动挂载。", + "description": "可选 Skill 拓展列表,默认选择当前用户可用的全部 Skill 拓展。" + "Skill 拓展依赖的工具和 MCP 服务器也会被自动挂载。", "type": "list", "kind": "skills", }, diff --git a/backend/package/yuxi/agents/toolkits/buildin/__init__.py b/backend/package/yuxi/agents/toolkits/buildin/__init__.py index 77d077f6..88903f78 100644 --- a/backend/package/yuxi/agents/toolkits/buildin/__init__.py +++ b/backend/package/yuxi/agents/toolkits/buildin/__init__.py @@ -1,9 +1,9 @@ # buildin 工具包 -# from .install_skill import install_skill +from .install_skill import install_skill from .tools import ask_user_question, present_artifacts __all__ = [ "ask_user_question", - # "install_skill", + "install_skill", "present_artifacts", ] diff --git a/backend/package/yuxi/agents/toolkits/buildin/install_skill.py b/backend/package/yuxi/agents/toolkits/buildin/install_skill.py index 74b4a2e4..f68f6462 100644 --- a/backend/package/yuxi/agents/toolkits/buildin/install_skill.py +++ b/backend/package/yuxi/agents/toolkits/buildin/install_skill.py @@ -1,288 +1,305 @@ -# import shutil -# import tempfile -# from pathlib import Path, PurePosixPath -# from typing import Annotated +import shutil +import tempfile +from pathlib import Path, PurePosixPath +from typing import Annotated -# from langchain.tools import InjectedToolCallId -# from langchain_core.messages import ToolMessage -# from langgraph.prebuilt.tool_node import ToolRuntime -# from langgraph.types import Command -# from pydantic import BaseModel, Field +from langchain.tools import InjectedToolCallId +from langchain_core.messages import ToolMessage +from langgraph.prebuilt.tool_node import ToolRuntime +from langgraph.types import Command +from pydantic import BaseModel, Field -# from yuxi.agents.toolkits.registry import tool -# from yuxi.repositories.agent_config_repository import AgentConfigRepository -# from yuxi.repositories.conversation_repository import ConversationRepository -# from yuxi.repositories.user_repository import UserRepository -# from yuxi.storage.postgres.manager import pg_manager -# from yuxi.utils.logging_config import logger +from yuxi.agents.toolkits.registry import tool +from yuxi.repositories.agent_repository import AgentRepository +from yuxi.repositories.conversation_repository import ConversationRepository +from yuxi.storage.postgres.manager import pg_manager +from yuxi.utils.logging_config import logger -# ADMIN_ROLES = {"admin", "superadmin"} -# SANDBOX_PATH_HINT = ( -# "请使用 /home/gem/user-data/workspace/...、/home/gem/user-data/uploads/... 或 /home/gem/user-data/outputs/..." -# ) +SANDBOX_PATH_HINT = ( + "请使用 /home/gem/user-data/workspace/...、/home/gem/user-data/uploads/... 或 /home/gem/user-data/outputs/..." +) +MAX_SANDBOX_SKILL_FILES = 1000 -# class InstallSkillInput(BaseModel): -# source: str = Field( -# description="Skill 来源,支持两种格式:\n" -# "1. Sandbox 路径: /home/gem/user-data/workspace/...、" -# "/home/gem/user-data/uploads/... 或 /home/gem/user-data/outputs/...(/ 开头)\n" -# "2. Git 仓库: owner/repo 或完整 GitHub URL" -# ) -# skill_names: list[str] | None = Field( -# default=None, description="Git 安装时指定要安装的 skill slug 列表(至少一个)。Sandbox 路径安装时忽略此参数。" -# ) +class InstallSkillInput(BaseModel): + source: str = Field( + description="Skill 来源,支持两种格式:\n" + "1. Sandbox 路径: /home/gem/user-data/workspace/...、" + "/home/gem/user-data/uploads/... 或 /home/gem/user-data/outputs/...(/ 开头)\n" + "2. Git 仓库: owner/repo 或完整 GitHub URL" + ) + skill_names: list[str] | None = Field( + default=None, description="Git 安装时指定要安装的 skill slug 列表(至少一个)。Sandbox 路径安装时忽略此参数。" + ) -# async def _assert_admin(db, uid: str) -> None: -# """验证用户是管理员,否则抛出 ValueError。""" -# repo = UserRepository() -# user = await repo.get_by_uid_with_db(db, uid) -# if user is None: -# raise ValueError("用户不存在") -# if user.role not in ADMIN_ROLES: -# raise ValueError("仅管理员可以安装 skill") +def _personal_skill_share_config(uid: str) -> dict: + return {"access_level": "user", "department_ids": [], "user_uids": [str(uid)]} -# def _collect_sandbox_file_paths(backend, remote_dir: str) -> list[str]: -# result = backend.ls(remote_dir) -# if result.error: -# raise ValueError(result.error) -# entries = result.entries or [] -# file_paths: list[str] = [] -# for entry in entries: -# path = entry["path"] -# if entry.get("is_dir"): -# file_paths.extend(_collect_sandbox_file_paths(backend, path)) -# else: -# file_paths.append(path) -# return file_paths +def _collect_sandbox_file_paths(backend, remote_dir: str, file_paths: list[str] | None = None) -> list[str]: + file_paths = file_paths if file_paths is not None else [] + result = backend.ls(remote_dir) + if result.error: + raise ValueError(result.error) + entries = result.entries or [] + for entry in entries: + path = entry["path"] + if entry.get("is_dir"): + _collect_sandbox_file_paths(backend, path, file_paths) + else: + if len(file_paths) >= MAX_SANDBOX_SKILL_FILES: + raise ValueError(f"Skill 目录文件数超过限制(最多 {MAX_SANDBOX_SKILL_FILES} 个文件)") + file_paths.append(path) + return file_paths -# def _download_skill_dir(backend, remote_dir: str, local_dir: Path) -> None: -# """递归通过沙盒 API 下载 skill 目录到本地。""" -# remote_root = PurePosixPath(remote_dir.rstrip("/")) -# file_paths = _collect_sandbox_file_paths(backend, remote_dir) -# if not file_paths: -# raise ValueError(f"沙盒路径 {remote_dir} 中未发现可下载文件") +def _download_skill_dir(backend, remote_dir: str, local_dir: Path) -> None: + """递归通过沙盒 API 下载 skill 目录到本地。""" + remote_root = PurePosixPath(remote_dir.rstrip("/")) + file_paths = _collect_sandbox_file_paths(backend, remote_dir) + if not file_paths: + raise ValueError(f"沙盒路径 {remote_dir} 中未发现可下载文件") -# responses = backend.download_files(file_paths) -# if len(responses) != len(file_paths): -# raise ValueError("沙盒文件下载结果数量不匹配") + responses = backend.download_files(file_paths) + if len(responses) != len(file_paths): + raise ValueError("沙盒文件下载结果数量不匹配") -# for expected_path, response in zip(file_paths, responses): -# error = getattr(response, "error", None) -# content = getattr(response, "content", None) -# if error or content is None: -# raise ValueError(f"下载沙盒文件失败: {expected_path} ({error or 'empty_content'})") + for expected_path, response in zip(file_paths, responses): + error = getattr(response, "error", None) + content = getattr(response, "content", None) + if error or content is None: + raise ValueError(f"下载沙盒文件失败: {expected_path} ({error or 'empty_content'})") -# pure_path = PurePosixPath(expected_path) -# try: -# relative_path = pure_path.relative_to(remote_root) -# except ValueError: -# relative_path = PurePosixPath(pure_path.name) + pure_path = PurePosixPath(expected_path) + try: + relative_path = pure_path.relative_to(remote_root) + except ValueError: + relative_path = PurePosixPath(pure_path.name) -# target_path = local_dir / Path(relative_path.as_posix()) -# target_path.parent.mkdir(parents=True, exist_ok=True) -# target_path.write_bytes(content) + target_path = local_dir / Path(relative_path.as_posix()) + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(content) -# def _prepare_skill_from_sandbox( -# sandbox_path: str, thread_id: str, uid: str, staging_root: Path -# ) -> tuple[Path, str]: -# """从 Sandbox 路径准备 skill 目录。返回 (本地目录, 原始 skill name)。""" -# from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend, resolve_virtual_path -# from yuxi.agents.skills.service import ( -# _parse_skill_markdown, -# is_valid_skill_slug, -# ) +def _prepare_skill_from_sandbox( + sandbox_path: str, thread_id: str, uid: str, staging_root: Path +) -> tuple[Path, str]: + """从 Sandbox 路径准备 skill 目录。返回 (本地目录, 原始 skill name)。""" + from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend, resolve_virtual_path + from yuxi.agents.skills.service import ( + _parse_skill_markdown, + is_valid_skill_slug, + ) -# slug = PurePosixPath(sandbox_path.rstrip("/")).name -# if not is_valid_skill_slug(slug): -# raise ValueError(f"slug '{slug}' 不合法(仅允许小写字母、数字和连字符)") + slug = PurePosixPath(sandbox_path.rstrip("/")).name + if not is_valid_skill_slug(slug): + raise ValueError(f"slug '{slug}' 不合法(仅允许小写字母、数字和连字符)") -# if not sandbox_path.startswith("/home/gem/user-data/"): -# raise ValueError(f"不支持的沙盒路径: {sandbox_path}。{SANDBOX_PATH_HINT}") + if not sandbox_path.startswith("/home/gem/user-data/"): + raise ValueError(f"不支持的沙盒路径: {sandbox_path}。{SANDBOX_PATH_HINT}") -# staging = staging_root / slug + staging = staging_root / slug -# # 优先尝试共享卷路径(性能更好,无需走沙盒 API) -# try: -# local_path = resolve_virtual_path(thread_id, sandbox_path, uid=uid) -# if (local_path / "SKILL.md").exists(): -# shutil.copytree(local_path, staging) -# else: -# raise FileNotFoundError(f"{local_path} 中未找到 SKILL.md") -# except (ValueError, FileNotFoundError): -# staging.mkdir(parents=True, exist_ok=True) -# backend = ProvisionerSandboxBackend(thread_id=thread_id, uid=uid) -# _download_skill_dir(backend, sandbox_path, staging) -# if not (staging / "SKILL.md").exists(): -# raise ValueError(f"沙盒路径 {sandbox_path} 中未找到 SKILL.md") + # 优先尝试共享卷路径(性能更好,无需走沙盒 API)。 + try: + local_path = resolve_virtual_path(thread_id, sandbox_path, uid=uid) + if (local_path / "SKILL.md").exists(): + shutil.copytree(local_path, staging) + else: + raise FileNotFoundError(f"{local_path} 中未找到 SKILL.md") + except FileNotFoundError: + staging.mkdir(parents=True, exist_ok=True) + backend = ProvisionerSandboxBackend(thread_id=thread_id, uid=uid) + _download_skill_dir(backend, sandbox_path, staging) + if not (staging / "SKILL.md").exists(): + raise ValueError(f"沙盒路径 {sandbox_path} 中未找到 SKILL.md") -# content = (staging / "SKILL.md").read_text(encoding="utf-8") -# parsed_name, _, _ = _parse_skill_markdown(content) -# return staging, parsed_name + content = (staging / "SKILL.md").read_text(encoding="utf-8") + parsed_name, _, _ = _parse_skill_markdown(content) + return staging, parsed_name -# async def _enable_skills_in_current_config(db, thread_id: str, skill_slugs: list[str]) -> bool: -# """在当前会话的配置中启用新安装的 skill""" -# conv_repo = ConversationRepository(db) -# conv = await conv_repo.get_conversation_by_thread_id(thread_id) -# if not conv: -# return False +async def _enable_skills_in_current_config(db, thread_id: str, uid: str, skill_slugs: list[str]) -> bool: + """在当前会话绑定且当前用户拥有的 Agent 配置中启用新安装的 skill。""" + conv_repo = ConversationRepository(db) + conv = await conv_repo.get_conversation_by_thread_id(thread_id) + if not conv or str(conv.uid) != str(uid): + return False -# agent_config_id = (conv.extra_metadata or {}).get("agent_config_id") -# if not agent_config_id: -# return False + agent_repo = AgentRepository(db) + agent = await agent_repo.get_by_slug(conv.agent_id) + if not agent or agent.created_by != str(uid): + return False -# config_repo = AgentConfigRepository(db) -# result = await config_repo.add_skills_to_config_json(agent_config_id=agent_config_id, new_slugs=skill_slugs) -# return result + config_json = dict(agent.config_json or {}) + context = dict(config_json.get("context") or {}) + skills = [item for item in context.get("skills") or [] if isinstance(item, str) and item.strip()] + seen = set(skills) + for slug in skill_slugs: + if slug not in seen: + skills.append(slug) + seen.add(slug) + context["skills"] = skills + config_json["context"] = context + await agent_repo.update(agent, config_json=config_json, updated_by=str(uid)) + return True -# async def _run_install_task( -# source: str, -# runtime: ToolRuntime, -# tool_call_id: str, -# skill_names: list[str] | None = None, -# ) -> Command: -# """执行异步安装任务的核心逻辑""" -# from yuxi.agents.skills.remote_install import prepare_remote_skills_batch -# from yuxi.agents.skills.service import ( -# import_skill_dir, -# normalize_string_list, -# sync_thread_readable_skills, -# ) +async def _run_install_task( + source: str, + runtime: ToolRuntime, + tool_call_id: str, + skill_names: list[str] | None = None, +) -> Command: + """执行异步安装任务的核心逻辑。""" + runtime_context = getattr(runtime, "context", None) + if getattr(runtime_context, "is_subagent_runtime", False): + return Command( + update={ + "messages": [ + ToolMessage( + content="错误:install_skill 只能在主智能体中使用,子智能体无法安装 Skill", + tool_call_id=tool_call_id, + ) + ] + } + ) -# uid = getattr(runtime.context, "uid", None) -# thread_id = getattr(runtime.context, "thread_id", None) + source = str(source or "").strip() + uid = getattr(runtime_context, "uid", None) + thread_id = getattr(runtime_context, "thread_id", None) -# logger.info(f"DEBUG: install_skill called with uid={uid}, thread_id={thread_id}, source={source}") + logger.info(f"install_skill called with uid={uid}, thread_id={thread_id}, source={source}") -# if not uid or not thread_id: -# return Command( -# update={"messages": [ToolMessage(content="错误:无法获取当前会话信息", tool_call_id=tool_call_id)]} -# ) + if not uid or not thread_id: + return Command( + update={"messages": [ToolMessage(content="错误:无法获取当前会话信息", tool_call_id=tool_call_id)]} + ) + if not source: + return Command( + update={"messages": [ToolMessage(content="错误:Skill 来源不能为空", tool_call_id=tool_call_id)]} + ) -# try: -# async with pg_manager.get_async_session_context() as db: -# await _assert_admin(db, uid) + personal_share_config = _personal_skill_share_config(uid) -# installed_slugs: list[str] = [] -# failed_items: list[dict] = [] -# slug_warnings: list[str] = [] -# config_success = True + try: + from yuxi.agents.skills.service import ( + import_skill_dir, + normalize_string_list, + sync_thread_readable_skills, + ) -# if source.startswith("/"): -# with tempfile.TemporaryDirectory(prefix=".skill-install-") as tmp: -# source_dir, parsed_name = _prepare_skill_from_sandbox(source, thread_id, uid, Path(tmp)) -# async with pg_manager.get_async_session_context() as db: -# await _assert_admin(db, uid) -# item = await import_skill_dir(db, source_dir=source_dir, created_by=uid) -# installed_slugs = [item.slug] -# if item.slug != parsed_name: -# slug_warnings.append(f"⚠️ 技能 slug '{item.slug}' 已存在,已自动重命名安装") -# config_success = await _enable_skills_in_current_config(db, thread_id, installed_slugs) -# else: -# _skill_names = skill_names or [] -# if not _skill_names: -# return Command( -# update={ -# "messages": [ -# ToolMessage( -# content="❌ 错误: 从 Git 安装时必须通过 skill_names 指定技能名称", -# tool_call_id=tool_call_id, -# ) -# ] -# } -# ) + installed_slugs: list[str] = [] + failed_items: list[dict] = [] + slug_warnings: list[str] = [] + config_success = True -# preparation = await prepare_remote_skills_batch(source=source, skills=_skill_names) -# try: -# async with pg_manager.get_async_session_context() as db: -# await _assert_admin(db, uid) -# for result in preparation.results: -# if not result.get("success"): -# failed_items.append(result) -# continue + if source.startswith("/"): + with tempfile.TemporaryDirectory(prefix=".skill-install-") as tmp: + source_dir, parsed_name = _prepare_skill_from_sandbox(source, thread_id, uid, Path(tmp)) + async with pg_manager.get_async_session_context() as db: + item = await import_skill_dir( + db, + source_dir=source_dir, + created_by=uid, + share_config=personal_share_config, + ) + installed_slugs = [item.slug] + if item.slug != parsed_name: + slug_warnings.append(f"⚠️ 技能 slug '{item.slug}' 已存在,已自动重命名安装") + config_success = await _enable_skills_in_current_config(db, thread_id, uid, installed_slugs) + else: + _skill_names = skill_names or [] + if not _skill_names: + return Command( + update={ + "messages": [ + ToolMessage( + content="❌ 错误: 从 Git 安装时必须通过 skill_names 指定技能名称", + tool_call_id=tool_call_id, + ) + ] + } + ) -# try: -# item = await import_skill_dir(db, source_dir=result["source_dir"], created_by=uid) -# installed_slugs.append(item.slug) -# except Exception as e: -# await db.rollback() -# failed_items.append({"slug": result["slug"], "success": False, "error": str(e)}) + from yuxi.agents.skills.remote_install import prepare_remote_skills_batch -# config_success = True -# if installed_slugs: -# config_success = await _enable_skills_in_current_config(db, thread_id, installed_slugs) -# finally: -# preparation.cleanup() + preparation = await prepare_remote_skills_batch(source=source, skills=_skill_names) + try: + async with pg_manager.get_async_session_context() as db: + for result in preparation.results: + if not result.get("success"): + failed_items.append(result) + continue -# # 文件同步 -# current_skills = normalize_string_list(getattr(runtime.context, "skills", None)) -# sync_thread_readable_skills(thread_id, normalize_string_list(current_skills + installed_slugs)) + try: + item = await import_skill_dir( + db, + source_dir=result["source_dir"], + created_by=uid, + share_config=personal_share_config, + ) + installed_slugs.append(item.slug) + except Exception as e: + await db.rollback() + failed_items.append({"slug": result["slug"], "success": False, "error": str(e)}) -# # 响应 -# lines = [] -# if installed_slugs: -# lines.append(f"✅ 成功安装并激活技能: {', '.join(installed_slugs)}") -# for w in slug_warnings: -# lines.append(w) -# if failed_items: -# for item in failed_items: -# lines.append(f"❌ 安装失败 ({item['slug']}): {item.get('error', '未知错误')}") -# if not config_success: -# lines.append("⚠️ 技能已安装到系统,但在当前会话配置中激活失败") -# if not installed_slugs and not failed_items: -# lines.append("ℹ️ 未发现需要安装的技能") + config_success = True + if installed_slugs: + config_success = await _enable_skills_in_current_config(db, thread_id, uid, installed_slugs) + finally: + preparation.cleanup() -# return Command( -# update={ -# "activated_skills": installed_slugs, -# "messages": [ToolMessage(content="\n".join(lines), tool_call_id=tool_call_id)], -# } -# ) + current_skills = normalize_string_list(getattr(runtime_context, "skills", None)) + sync_thread_readable_skills(thread_id, normalize_string_list(current_skills + installed_slugs)) -# except Exception as e: -# logger.exception("install_skill 异常") -# return Command( -# update={ -# "messages": [ -# ToolMessage( -# content=f"❌ 安装异常: {str(e)}", -# tool_call_id=tool_call_id, -# ) -# ] -# } -# ) + lines = [] + if installed_slugs: + lines.append(f"✅ 成功安装并激活技能: {', '.join(installed_slugs)}") + for warning in slug_warnings: + lines.append(warning) + if failed_items: + for item in failed_items: + lines.append(f"❌ 安装失败 ({item['slug']}): {item.get('error', '未知错误')}") + if not config_success: + lines.append("⚠️ 已添加这个 Skill 拓展(仅当前会话生效,未写入当前 Agent 配置)") + if not installed_slugs and not failed_items: + lines.append("ℹ️ 未发现需要安装的技能") + + return Command( + update={ + "activated_skills": installed_slugs, + "messages": [ToolMessage(content="\n".join(lines), tool_call_id=tool_call_id)], + } + ) + + except Exception as e: + logger.exception("install_skill 异常") + return Command( + update={ + "messages": [ + ToolMessage( + content=f"❌ 安装异常: {str(e)}", + tool_call_id=tool_call_id, + ) + ] + } + ) -# @tool( -# category="buildin", -# tags=["skill", "安装"], -# display_name="安装技能", -# args_schema=InstallSkillInput, -# ) -# async def install_skill( -# source: str, -# skill_names: list[str] | None = None, -# runtime: ToolRuntime = None, -# tool_call_id: Annotated[str, InjectedToolCallId] = "", -# ) -> Command: -# """安装新的技能 (Skill) 到系统中。 - -# 参数说明: -# - source: 必填。支持两种格式: -# 1. Sandbox 路径: /home/gem/user-data/workspace/...、/home/gem/user-data/uploads/... -# 或 /home/gem/user-data/outputs/... -# 2. Git 仓库: 例如 "owner/repo" 或 "https://github.com/owner/repo" -# - skill_names: 从 Git 仓库安装时必填,指定要安装的技能列表。 - -# 注意: -# - 仅管理员 (admin/superadmin) 有权执行此操作。 -# - 安装成功后,该技能会自动在当前会话 (thread) 中激活。 -# """ -# return await _run_install_task(source, runtime, tool_call_id, skill_names) +@tool( + category="buildin", + tags=["skill", "安装"], + display_name="安装技能", + args_schema=InstallSkillInput, +) +async def install_skill( + source: str, + skill_names: list[str] | None = None, + runtime: ToolRuntime = None, + tool_call_id: Annotated[str, InjectedToolCallId] = "", +) -> Command: + """安装新的 Skill 到当前用户的私有空间,并在当前主智能体会话中激活。""" + return await _run_install_task(source, runtime, tool_call_id, skill_names) diff --git a/backend/test/unit/agents/test_subagent_tool_filter.py b/backend/test/unit/agents/test_subagent_tool_filter.py index ac505d28..41e0a96e 100644 --- a/backend/test/unit/agents/test_subagent_tool_filter.py +++ b/backend/test/unit/agents/test_subagent_tool_filter.py @@ -20,6 +20,7 @@ def test_filter_disabled_tools_keeps_allowed_tools_order(): SimpleNamespace(name="search"), SimpleNamespace(name="present_artifacts"), {"name": "ask_user_question"}, + SimpleNamespace(name="install_skill"), SimpleNamespace(name="calculator"), ] @@ -80,6 +81,7 @@ async def test_subagent_get_info_hides_disabled_tool_options(monkeypatch): {"key": "present_artifacts", "name": "展示交付物"}, {"key": "allowed_tool", "name": "Allowed"}, {"key": "ask_user_question", "name": "向用户提问"}, + {"key": "install_skill", "name": "安装技能"}, ] } }, diff --git a/backend/test/unit/toolkits/test_install_skill.py b/backend/test/unit/toolkits/test_install_skill.py new file mode 100644 index 00000000..f6135af9 --- /dev/null +++ b/backend/test/unit/toolkits/test_install_skill.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import importlib +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from yuxi.agents.skills import service as skill_service +from yuxi.agents.toolkits.buildin import install_skill as exported_install_skill + +install_skill_module = importlib.import_module("yuxi.agents.toolkits.buildin.install_skill") + + +class _AsyncSessionContext: + def __init__(self, db): + self.db = db + + async def __aenter__(self): + return self.db + + async def __aexit__(self, *_args): + return False + + +def _runtime(**context_values): + return SimpleNamespace(context=SimpleNamespace(**context_values)) + + +@pytest.mark.asyncio +async def test_install_skill_from_sandbox_installs_as_current_user_private_skill(monkeypatch, tmp_path: Path): + assert exported_install_skill.name == "install_skill" + + calls = {} + db = SimpleNamespace() + source_dir = tmp_path / "demo-skill" + + def prepare_skill_from_sandbox(source, thread_id, uid, staging_root): + calls["prepare"] = { + "source": source, + "thread_id": thread_id, + "uid": uid, + "staging_root": staging_root, + } + return source_dir, "demo-skill" + + async def import_skill_dir(db_arg, **kwargs): + calls["import"] = {"db": db_arg, **kwargs} + return SimpleNamespace(slug="demo-skill") + + async def enable_skills(db_arg, thread_id, uid, skill_slugs): + calls["enable"] = {"db": db_arg, "thread_id": thread_id, "uid": uid, "skill_slugs": skill_slugs} + return True + + def sync_thread_readable_skills(thread_id, skills): + calls["sync"] = {"thread_id": thread_id, "skills": skills} + + monkeypatch.setattr( + install_skill_module, + "_prepare_skill_from_sandbox", + prepare_skill_from_sandbox, + ) + monkeypatch.setattr( + install_skill_module, + "_enable_skills_in_current_config", + enable_skills, + ) + monkeypatch.setattr( + install_skill_module.pg_manager, + "get_async_session_context", + lambda: _AsyncSessionContext(db), + ) + monkeypatch.setattr(skill_service, "import_skill_dir", import_skill_dir) + monkeypatch.setattr(skill_service, "sync_thread_readable_skills", sync_thread_readable_skills) + + result = await install_skill_module._run_install_task( + " /home/gem/user-data/workspace/demo-skill ", + _runtime(uid="normal-user", thread_id="thread-1", skills=["existing-skill"]), + "tool-1", + ) + + assert result.update["activated_skills"] == ["demo-skill"] + assert "成功安装并激活技能" in result.update["messages"][0].content + assert calls["prepare"]["uid"] == "normal-user" + assert calls["import"]["created_by"] == "normal-user" + assert calls["import"]["share_config"] == { + "access_level": "user", + "department_ids": [], + "user_uids": ["normal-user"], + } + assert calls["prepare"]["source"] == "/home/gem/user-data/workspace/demo-skill" + assert calls["enable"] == { + "db": db, + "thread_id": "thread-1", + "uid": "normal-user", + "skill_slugs": ["demo-skill"], + } + assert calls["sync"] == {"thread_id": "thread-1", "skills": ["existing-skill", "demo-skill"]} + + +@pytest.mark.asyncio +async def test_install_skill_rejects_subagent_runtime_before_install(monkeypatch): + def fail_get_session(): + raise AssertionError("子智能体运行态不应访问数据库或执行安装") + + monkeypatch.setattr( + install_skill_module.pg_manager, + "get_async_session_context", + fail_get_session, + ) + + result = await install_skill_module._run_install_task( + "/home/gem/user-data/workspace/demo-skill", + _runtime(uid="user-1", thread_id="child-thread", is_subagent_runtime=True), + "tool-1", + ) + + assert "只能在主智能体中使用" in result.update["messages"][0].content + assert "activated_skills" not in result.update + + +@pytest.mark.asyncio +async def test_install_skill_git_source_requires_skill_names(): + result = await install_skill_module._run_install_task( + "owner/repo", + _runtime(uid="user-1", thread_id="thread-1"), + "tool-1", + ) + + assert "必须通过 skill_names 指定技能名称" in result.update["messages"][0].content + + +@pytest.mark.asyncio +async def test_install_skill_rejects_empty_source(): + result = await install_skill_module._run_install_task( + " ", + _runtime(uid="user-1", thread_id="thread-1"), + "tool-1", + ) + + assert "Skill 来源不能为空" in result.update["messages"][0].content + + +@pytest.mark.asyncio +async def test_enable_skills_updates_current_user_owned_agent_config(monkeypatch): + conv = SimpleNamespace(uid="user-1", agent_id="agent-1") + agent = SimpleNamespace( + created_by="user-1", + config_json={"context": {"skills": ["existing-skill"], "model": "provider:model"}}, + ) + calls = {} + + class FakeConversationRepository: + def __init__(self, db): + self.db = db + + async def get_conversation_by_thread_id(self, thread_id): + calls["thread_id"] = thread_id + return conv + + class FakeAgentRepository: + def __init__(self, db): + self.db = db + + async def get_by_slug(self, slug): + calls["agent_slug"] = slug + return agent + + async def update(self, agent_arg, **kwargs): + calls["update"] = {"agent": agent_arg, **kwargs} + return agent_arg + + monkeypatch.setattr(install_skill_module, "ConversationRepository", FakeConversationRepository) + monkeypatch.setattr(install_skill_module, "AgentRepository", FakeAgentRepository) + + result = await install_skill_module._enable_skills_in_current_config( + SimpleNamespace(), + "thread-1", + "user-1", + ["existing-skill", "new-skill"], + ) + + assert result is True + assert calls["thread_id"] == "thread-1" + assert calls["agent_slug"] == "agent-1" + assert calls["update"]["updated_by"] == "user-1" + assert calls["update"]["config_json"] == { + "context": {"skills": ["existing-skill", "new-skill"], "model": "provider:model"} + } + + +@pytest.mark.asyncio +async def test_enable_skills_does_not_update_agent_not_owned_by_current_user(monkeypatch): + conv = SimpleNamespace(uid="user-1", agent_id="shared-agent") + agent = SimpleNamespace(created_by="admin", config_json={"context": {}}) + calls = {} + + class FakeConversationRepository: + def __init__(self, db): + self.db = db + + async def get_conversation_by_thread_id(self, _thread_id): + return conv + + class FakeAgentRepository: + def __init__(self, db): + self.db = db + + async def get_by_slug(self, _slug): + return agent + + async def update(self, *_args, **_kwargs): + calls["updated"] = True + + monkeypatch.setattr(install_skill_module, "ConversationRepository", FakeConversationRepository) + monkeypatch.setattr(install_skill_module, "AgentRepository", FakeAgentRepository) + + result = await install_skill_module._enable_skills_in_current_config( + SimpleNamespace(), + "thread-1", + "user-1", + ["new-skill"], + ) + + assert result is False + assert "updated" not in calls + + +@pytest.mark.asyncio +async def test_enable_skills_does_not_update_mismatched_runtime_uid(monkeypatch): + conv = SimpleNamespace(uid="user-1", agent_id="agent-1") + calls = {} + + class FakeConversationRepository: + def __init__(self, db): + self.db = db + + async def get_conversation_by_thread_id(self, _thread_id): + return conv + + class FakeAgentRepository: + def __init__(self, db): + self.db = db + + async def get_by_slug(self, *_args): + calls["loaded_agent"] = True + + monkeypatch.setattr(install_skill_module, "ConversationRepository", FakeConversationRepository) + monkeypatch.setattr(install_skill_module, "AgentRepository", FakeAgentRepository) + + result = await install_skill_module._enable_skills_in_current_config( + SimpleNamespace(), + "thread-1", + "other-user", + ["new-skill"], + ) + + assert result is False + assert "loaded_agent" not in calls + + +def test_prepare_skill_invalid_virtual_path_does_not_fallback_to_sandbox(monkeypatch, tmp_path: Path): + calls = {} + + def resolve_virtual_path(*_args, **_kwargs): + raise ValueError("path traversal detected") + + class FakeProvisionerSandboxBackend: + def __init__(self, *_args, **_kwargs): + calls["fallback"] = True + + monkeypatch.setattr( + "yuxi.agents.backends.sandbox.resolve_virtual_path", + resolve_virtual_path, + ) + monkeypatch.setattr( + "yuxi.agents.backends.sandbox.ProvisionerSandboxBackend", + FakeProvisionerSandboxBackend, + ) + monkeypatch.setattr(skill_service, "is_valid_skill_slug", lambda _slug: True) + + with pytest.raises(ValueError, match="path traversal detected"): + install_skill_module._prepare_skill_from_sandbox( + "/home/gem/user-data/workspace/demo-skill", + "thread-1", + "user-1", + tmp_path, + ) + + assert "fallback" not in calls + + +def test_collect_sandbox_file_paths_rejects_more_than_1000_files(): + class FakeBackend: + def ls(self, _remote_dir): + return SimpleNamespace( + error=None, + entries=[{"path": f"/skill/file-{idx}.txt", "is_dir": False} for idx in range(1001)], + ) + + with pytest.raises(ValueError, match="最多 1000 个文件"): + install_skill_module._collect_sandbox_file_paths(FakeBackend(), "/skill") diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index a9e6192b..ded6f993 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -49,7 +49,7 @@ - 新增 Milvus 图谱检索链路:Query 可召回图谱实体和三元组,结合 Chunk 命中实体构造 seed entity,读取 Neo4j 2-hop 子图后用 igraph 执行 PPR,最终以 Chunk 为产物并通过 RRF 与原 Chunk 召回融合;检索配置改为 dataclass 元数据生成,支持 `depend_on` 控制重排序和图检索参数展示。 - 收紧用户管理部门隔离:普通管理员创建用户时固定归属本部门,用户列表、访问选项、详情、更新和删除接口均限制在本部门范围内。 - 调整 Agent 资源默认选择与运行时上下文:未显式配置工具、知识库、MCP、Skills 时默认启用当前用户可访问/可用的全部资源,子智能体默认不启用,显式保存空列表仍表示不启用对应资源;Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生,聊天运行时与文件系统预览复用同一结果。 -- 重构 Skills 权限与安装流程:Skill 增加 `source_type/share_config/enabled`,内置 Skill 作为启动同步入库的全局资源,不再保留前端安装/更新状态,支持启停但不允许删除;上传和远程添加统一为解析草稿后确认生效范围,安装 slug 优先读取 `SKILL.md` 的 `slug` 字段并保留 `name` 展示名,压缩包名称不参与 slug 校验;管理端支持编辑生效范围与启停;Agent 运行时按当前用户可访问 Skills 派生 prompt/readable 依赖闭包并限制挂载/激活,Skills prompt 改为模型请求级注入以避免污染 runtime context。 +- 重构 Skills 权限与安装流程:Skill 增加 `source_type/share_config/enabled`,内置 Skill 作为启动同步入库的全局资源,不再保留前端安装/更新状态,支持启停但不允许删除;上传和远程添加统一为解析草稿后确认生效范围,安装 slug 优先读取 `SKILL.md` 的 `slug` 字段并保留 `name` 展示名,压缩包名称不参与 slug 校验;管理端支持编辑生效范围与启停;Agent 运行时按当前用户可访问 Skills 派生 prompt/readable 依赖闭包并限制挂载/激活,Skills prompt 改为模型请求级注入以避免污染 runtime context;主智能体恢复 `install_skill` 工具,允许当前用户安装私有 Skill 并激活当前会话,子智能体配置和运行态均禁用该工具。 - 精简历史兼容层:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端若干旧字段回退。 - 重构知识库共享权限:`share_config` 改为全局共享、部门共享、指定人可访问三档,部门共享必须包含当前用户部门,指定人可访问必须包含当前用户,并补充权限过滤测试。 - 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树,Agent 继续使用 `query_kb` 与 `open_kb_document` 访问知识库内容。