feat: 启用主智能体私有 Skill 安装工具

This commit is contained in:
Wenjie Zhang 2026-06-03 21:04:12 +08:00
parent dc6443755f
commit 27dfacbd62
7 changed files with 571 additions and 250 deletions

View File

@ -15,7 +15,7 @@ from yuxi.agents.middlewares.knowledge_base import KnowledgeBaseMiddleware
from yuxi.agents.middlewares.skills import SkillsMiddleware from yuxi.agents.middlewares.skills import SkillsMiddleware
from yuxi.agents.toolkits.service import resolve_configured_runtime_tools 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: def _tool_name(tool) -> str | None:

View File

@ -179,8 +179,8 @@ class BaseContext:
metadata={ metadata={
"name": "Skills", "name": "Skills",
"options": [], "options": [],
"description": "可选技能列表(由超级管理员维护),默认选择当前用户可用的全部 skills" "description": "可选 Skill 拓展列表,默认选择当前用户可用的全部 Skill 拓展"
"技能依赖的工具和 MCP 服务器也会被自动挂载。", "Skill 拓展依赖的工具和 MCP 服务器也会被自动挂载。",
"type": "list", "type": "list",
"kind": "skills", "kind": "skills",
}, },

View File

@ -1,9 +1,9 @@
# buildin 工具包 # buildin 工具包
# from .install_skill import install_skill from .install_skill import install_skill
from .tools import ask_user_question, present_artifacts from .tools import ask_user_question, present_artifacts
__all__ = [ __all__ = [
"ask_user_question", "ask_user_question",
# "install_skill", "install_skill",
"present_artifacts", "present_artifacts",
] ]

View File

@ -1,288 +1,305 @@
# import shutil import shutil
# import tempfile import tempfile
# from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
# from typing import Annotated from typing import Annotated
# from langchain.tools import InjectedToolCallId from langchain.tools import InjectedToolCallId
# from langchain_core.messages import ToolMessage from langchain_core.messages import ToolMessage
# from langgraph.prebuilt.tool_node import ToolRuntime from langgraph.prebuilt.tool_node import ToolRuntime
# from langgraph.types import Command from langgraph.types import Command
# from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# from yuxi.agents.toolkits.registry import tool from yuxi.agents.toolkits.registry import tool
# from yuxi.repositories.agent_config_repository import AgentConfigRepository from yuxi.repositories.agent_repository import AgentRepository
# from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.repositories.conversation_repository import ConversationRepository
# from yuxi.repositories.user_repository import UserRepository from yuxi.storage.postgres.manager import pg_manager
# from yuxi.storage.postgres.manager import pg_manager from yuxi.utils.logging_config import logger
# from yuxi.utils.logging_config import logger
# ADMIN_ROLES = {"admin", "superadmin"} SANDBOX_PATH_HINT = (
# SANDBOX_PATH_HINT = ( "请使用 /home/gem/user-data/workspace/...、/home/gem/user-data/uploads/... 或 /home/gem/user-data/outputs/..."
# "请使用 /home/gem/user-data/workspace/...、/home/gem/user-data/uploads/... 或 /home/gem/user-data/outputs/..." )
# ) MAX_SANDBOX_SKILL_FILES = 1000
# class InstallSkillInput(BaseModel): class InstallSkillInput(BaseModel):
# source: str = Field( source: str = Field(
# description="Skill 来源,支持两种格式:\n" description="Skill 来源,支持两种格式:\n"
# "1. Sandbox 路径: /home/gem/user-data/workspace/...、" "1. Sandbox 路径: /home/gem/user-data/workspace/...、"
# "/home/gem/user-data/uploads/... 或 /home/gem/user-data/outputs/.../ 开头)\n" "/home/gem/user-data/uploads/... 或 /home/gem/user-data/outputs/.../ 开头)\n"
# "2. Git 仓库: owner/repo 或完整 GitHub URL" "2. Git 仓库: owner/repo 或完整 GitHub URL"
# ) )
# skill_names: list[str] | None = Field( skill_names: list[str] | None = Field(
# default=None, description="Git 安装时指定要安装的 skill slug 列表至少一个。Sandbox 路径安装时忽略此参数。" default=None, description="Git 安装时指定要安装的 skill slug 列表至少一个。Sandbox 路径安装时忽略此参数。"
# ) )
# async def _assert_admin(db, uid: str) -> None: def _personal_skill_share_config(uid: str) -> dict:
# """验证用户是管理员,否则抛出 ValueError。""" return {"access_level": "user", "department_ids": [], "user_uids": [str(uid)]}
# 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 _collect_sandbox_file_paths(backend, remote_dir: str) -> list[str]: def _collect_sandbox_file_paths(backend, remote_dir: str, file_paths: list[str] | None = None) -> list[str]:
# result = backend.ls(remote_dir) file_paths = file_paths if file_paths is not None else []
# if result.error: result = backend.ls(remote_dir)
# raise ValueError(result.error) if result.error:
# entries = result.entries or [] raise ValueError(result.error)
# file_paths: list[str] = [] entries = result.entries or []
# for entry in entries: for entry in entries:
# path = entry["path"] path = entry["path"]
# if entry.get("is_dir"): if entry.get("is_dir"):
# file_paths.extend(_collect_sandbox_file_paths(backend, path)) _collect_sandbox_file_paths(backend, path, file_paths)
# else: else:
# file_paths.append(path) if len(file_paths) >= MAX_SANDBOX_SKILL_FILES:
# return file_paths 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: def _download_skill_dir(backend, remote_dir: str, local_dir: Path) -> None:
# """递归通过沙盒 API 下载 skill 目录到本地。""" """递归通过沙盒 API 下载 skill 目录到本地。"""
# remote_root = PurePosixPath(remote_dir.rstrip("/")) remote_root = PurePosixPath(remote_dir.rstrip("/"))
# file_paths = _collect_sandbox_file_paths(backend, remote_dir) file_paths = _collect_sandbox_file_paths(backend, remote_dir)
# if not file_paths: if not file_paths:
# raise ValueError(f"沙盒路径 {remote_dir} 中未发现可下载文件") raise ValueError(f"沙盒路径 {remote_dir} 中未发现可下载文件")
# responses = backend.download_files(file_paths) responses = backend.download_files(file_paths)
# if len(responses) != len(file_paths): if len(responses) != len(file_paths):
# raise ValueError("沙盒文件下载结果数量不匹配") raise ValueError("沙盒文件下载结果数量不匹配")
# for expected_path, response in zip(file_paths, responses): for expected_path, response in zip(file_paths, responses):
# error = getattr(response, "error", None) error = getattr(response, "error", None)
# content = getattr(response, "content", None) content = getattr(response, "content", None)
# if error or content is None: if error or content is None:
# raise ValueError(f"下载沙盒文件失败: {expected_path} ({error or 'empty_content'})") raise ValueError(f"下载沙盒文件失败: {expected_path} ({error or 'empty_content'})")
# pure_path = PurePosixPath(expected_path) pure_path = PurePosixPath(expected_path)
# try: try:
# relative_path = pure_path.relative_to(remote_root) relative_path = pure_path.relative_to(remote_root)
# except ValueError: except ValueError:
# relative_path = PurePosixPath(pure_path.name) relative_path = PurePosixPath(pure_path.name)
# target_path = local_dir / Path(relative_path.as_posix()) target_path = local_dir / Path(relative_path.as_posix())
# target_path.parent.mkdir(parents=True, exist_ok=True) target_path.parent.mkdir(parents=True, exist_ok=True)
# target_path.write_bytes(content) target_path.write_bytes(content)
# def _prepare_skill_from_sandbox( def _prepare_skill_from_sandbox(
# sandbox_path: str, thread_id: str, uid: str, staging_root: Path sandbox_path: str, thread_id: str, uid: str, staging_root: Path
# ) -> tuple[Path, str]: ) -> tuple[Path, str]:
# """从 Sandbox 路径准备 skill 目录。返回 (本地目录, 原始 skill name)。""" """从 Sandbox 路径准备 skill 目录。返回 (本地目录, 原始 skill name)。"""
# from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend, resolve_virtual_path from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend, resolve_virtual_path
# from yuxi.agents.skills.service import ( from yuxi.agents.skills.service import (
# _parse_skill_markdown, _parse_skill_markdown,
# is_valid_skill_slug, is_valid_skill_slug,
# ) )
# slug = PurePosixPath(sandbox_path.rstrip("/")).name slug = PurePosixPath(sandbox_path.rstrip("/")).name
# if not is_valid_skill_slug(slug): if not is_valid_skill_slug(slug):
# raise ValueError(f"slug '{slug}' 不合法(仅允许小写字母、数字和连字符)") raise ValueError(f"slug '{slug}' 不合法(仅允许小写字母、数字和连字符)")
# if not sandbox_path.startswith("/home/gem/user-data/"): if not sandbox_path.startswith("/home/gem/user-data/"):
# raise ValueError(f"不支持的沙盒路径: {sandbox_path}。{SANDBOX_PATH_HINT}") raise ValueError(f"不支持的沙盒路径: {sandbox_path}{SANDBOX_PATH_HINT}")
# staging = staging_root / slug staging = staging_root / slug
# # 优先尝试共享卷路径(性能更好,无需走沙盒 API # 优先尝试共享卷路径(性能更好,无需走沙盒 API
# try: try:
# local_path = resolve_virtual_path(thread_id, sandbox_path, uid=uid) local_path = resolve_virtual_path(thread_id, sandbox_path, uid=uid)
# if (local_path / "SKILL.md").exists(): if (local_path / "SKILL.md").exists():
# shutil.copytree(local_path, staging) shutil.copytree(local_path, staging)
# else: else:
# raise FileNotFoundError(f"{local_path} 中未找到 SKILL.md") raise FileNotFoundError(f"{local_path} 中未找到 SKILL.md")
# except (ValueError, FileNotFoundError): except FileNotFoundError:
# staging.mkdir(parents=True, exist_ok=True) staging.mkdir(parents=True, exist_ok=True)
# backend = ProvisionerSandboxBackend(thread_id=thread_id, uid=uid) backend = ProvisionerSandboxBackend(thread_id=thread_id, uid=uid)
# _download_skill_dir(backend, sandbox_path, staging) _download_skill_dir(backend, sandbox_path, staging)
# if not (staging / "SKILL.md").exists(): if not (staging / "SKILL.md").exists():
# raise ValueError(f"沙盒路径 {sandbox_path} 中未找到 SKILL.md") raise ValueError(f"沙盒路径 {sandbox_path} 中未找到 SKILL.md")
# content = (staging / "SKILL.md").read_text(encoding="utf-8") content = (staging / "SKILL.md").read_text(encoding="utf-8")
# parsed_name, _, _ = _parse_skill_markdown(content) parsed_name, _, _ = _parse_skill_markdown(content)
# return staging, parsed_name return staging, parsed_name
# async def _enable_skills_in_current_config(db, thread_id: str, skill_slugs: list[str]) -> bool: async def _enable_skills_in_current_config(db, thread_id: str, uid: str, skill_slugs: list[str]) -> bool:
# """在当前会话的配置中启用新安装的 skill""" """在当前会话绑定且当前用户拥有的 Agent 配置中启用新安装的 skill。"""
# conv_repo = ConversationRepository(db) conv_repo = ConversationRepository(db)
# conv = await conv_repo.get_conversation_by_thread_id(thread_id) conv = await conv_repo.get_conversation_by_thread_id(thread_id)
# if not conv: if not conv or str(conv.uid) != str(uid):
# return False return False
# agent_config_id = (conv.extra_metadata or {}).get("agent_config_id") agent_repo = AgentRepository(db)
# if not agent_config_id: agent = await agent_repo.get_by_slug(conv.agent_id)
# return False if not agent or agent.created_by != str(uid):
return False
# config_repo = AgentConfigRepository(db) config_json = dict(agent.config_json or {})
# result = await config_repo.add_skills_to_config_json(agent_config_id=agent_config_id, new_slugs=skill_slugs) context = dict(config_json.get("context") or {})
# return result 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( async def _run_install_task(
# source: str, source: str,
# runtime: ToolRuntime, runtime: ToolRuntime,
# tool_call_id: str, tool_call_id: str,
# skill_names: list[str] | None = None, skill_names: list[str] | None = None,
# ) -> Command: ) -> Command:
# """执行异步安装任务的核心逻辑""" """执行异步安装任务的核心逻辑。"""
# from yuxi.agents.skills.remote_install import prepare_remote_skills_batch runtime_context = getattr(runtime, "context", None)
# from yuxi.agents.skills.service import ( if getattr(runtime_context, "is_subagent_runtime", False):
# import_skill_dir, return Command(
# normalize_string_list, update={
# sync_thread_readable_skills, "messages": [
# ) ToolMessage(
content="错误install_skill 只能在主智能体中使用,子智能体无法安装 Skill",
tool_call_id=tool_call_id,
)
]
}
)
# uid = getattr(runtime.context, "uid", None) source = str(source or "").strip()
# thread_id = getattr(runtime.context, "thread_id", None) 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: if not uid or not thread_id:
# return Command( return Command(
# update={"messages": [ToolMessage(content="错误:无法获取当前会话信息", tool_call_id=tool_call_id)]} 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: personal_share_config = _personal_skill_share_config(uid)
# async with pg_manager.get_async_session_context() as db:
# await _assert_admin(db, uid)
# installed_slugs: list[str] = [] try:
# failed_items: list[dict] = [] from yuxi.agents.skills.service import (
# slug_warnings: list[str] = [] import_skill_dir,
# config_success = True normalize_string_list,
sync_thread_readable_skills,
)
# if source.startswith("/"): installed_slugs: list[str] = []
# with tempfile.TemporaryDirectory(prefix=".skill-install-") as tmp: failed_items: list[dict] = []
# source_dir, parsed_name = _prepare_skill_from_sandbox(source, thread_id, uid, Path(tmp)) slug_warnings: list[str] = []
# async with pg_manager.get_async_session_context() as db: config_success = True
# 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,
# )
# ]
# }
# )
# preparation = await prepare_remote_skills_batch(source=source, skills=_skill_names) if source.startswith("/"):
# try: with tempfile.TemporaryDirectory(prefix=".skill-install-") as tmp:
# async with pg_manager.get_async_session_context() as db: source_dir, parsed_name = _prepare_skill_from_sandbox(source, thread_id, uid, Path(tmp))
# await _assert_admin(db, uid) async with pg_manager.get_async_session_context() as db:
# for result in preparation.results: item = await import_skill_dir(
# if not result.get("success"): db,
# failed_items.append(result) source_dir=source_dir,
# continue 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: from yuxi.agents.skills.remote_install import prepare_remote_skills_batch
# 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)})
# config_success = True preparation = await prepare_remote_skills_batch(source=source, skills=_skill_names)
# if installed_slugs: try:
# config_success = await _enable_skills_in_current_config(db, thread_id, installed_slugs) async with pg_manager.get_async_session_context() as db:
# finally: for result in preparation.results:
# preparation.cleanup() if not result.get("success"):
failed_items.append(result)
continue
# # 文件同步 try:
# current_skills = normalize_string_list(getattr(runtime.context, "skills", None)) item = await import_skill_dir(
# sync_thread_readable_skills(thread_id, normalize_string_list(current_skills + installed_slugs)) 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)})
# # 响应 config_success = True
# lines = [] if installed_slugs:
# if installed_slugs: config_success = await _enable_skills_in_current_config(db, thread_id, uid, installed_slugs)
# lines.append(f"✅ 成功安装并激活技能: {', '.join(installed_slugs)}") finally:
# for w in slug_warnings: preparation.cleanup()
# 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(" 未发现需要安装的技能")
# return Command( current_skills = normalize_string_list(getattr(runtime_context, "skills", None))
# update={ sync_thread_readable_skills(thread_id, normalize_string_list(current_skills + installed_slugs))
# "activated_skills": installed_slugs,
# "messages": [ToolMessage(content="\n".join(lines), tool_call_id=tool_call_id)],
# }
# )
# except Exception as e: lines = []
# logger.exception("install_skill 异常") if installed_slugs:
# return Command( lines.append(f"✅ 成功安装并激活技能: {', '.join(installed_slugs)}")
# update={ for warning in slug_warnings:
# "messages": [ lines.append(warning)
# ToolMessage( if failed_items:
# content=f"❌ 安装异常: {str(e)}", for item in failed_items:
# tool_call_id=tool_call_id, 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( @tool(
# category="buildin", category="buildin",
# tags=["skill", "安装"], tags=["skill", "安装"],
# display_name="安装技能", display_name="安装技能",
# args_schema=InstallSkillInput, args_schema=InstallSkillInput,
# ) )
# async def install_skill( async def install_skill(
# source: str, source: str,
# skill_names: list[str] | None = None, skill_names: list[str] | None = None,
# runtime: ToolRuntime = None, runtime: ToolRuntime = None,
# tool_call_id: Annotated[str, InjectedToolCallId] = "", tool_call_id: Annotated[str, InjectedToolCallId] = "",
# ) -> Command: ) -> Command:
# """安装新的技能 (Skill) 到系统中。 """安装新的 Skill 到当前用户的私有空间,并在当前主智能体会话中激活。"""
return await _run_install_task(source, runtime, tool_call_id, skill_names)
# 参数说明:
# - 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)

View File

@ -20,6 +20,7 @@ def test_filter_disabled_tools_keeps_allowed_tools_order():
SimpleNamespace(name="search"), SimpleNamespace(name="search"),
SimpleNamespace(name="present_artifacts"), SimpleNamespace(name="present_artifacts"),
{"name": "ask_user_question"}, {"name": "ask_user_question"},
SimpleNamespace(name="install_skill"),
SimpleNamespace(name="calculator"), SimpleNamespace(name="calculator"),
] ]
@ -80,6 +81,7 @@ async def test_subagent_get_info_hides_disabled_tool_options(monkeypatch):
{"key": "present_artifacts", "name": "展示交付物"}, {"key": "present_artifacts", "name": "展示交付物"},
{"key": "allowed_tool", "name": "Allowed"}, {"key": "allowed_tool", "name": "Allowed"},
{"key": "ask_user_question", "name": "向用户提问"}, {"key": "ask_user_question", "name": "向用户提问"},
{"key": "install_skill", "name": "安装技能"},
] ]
} }
}, },

View File

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

View File

@ -49,7 +49,7 @@
- 新增 Milvus 图谱检索链路Query 可召回图谱实体和三元组,结合 Chunk 命中实体构造 seed entity读取 Neo4j 2-hop 子图后用 igraph 执行 PPR最终以 Chunk 为产物并通过 RRF 与原 Chunk 召回融合;检索配置改为 dataclass 元数据生成,支持 `depend_on` 控制重排序和图检索参数展示。 - 新增 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 依赖闭包派生,聊天运行时与文件系统预览复用同一结果。 - 调整 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 兼容和前端若干旧字段回退。 - 精简历史兼容层:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端若干旧字段回退。
- 重构知识库共享权限:`share_config` 改为全局共享、部门共享、指定人可访问三档,部门共享必须包含当前用户部门,指定人可访问必须包含当前用户,并补充权限过滤测试。 - 重构知识库共享权限:`share_config` 改为全局共享、部门共享、指定人可访问三档,部门共享必须包含当前用户部门,指定人可访问必须包含当前用户,并补充权限过滤测试。
- 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树Agent 继续使用 `query_kb``open_kb_document` 访问知识库内容。 - 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树Agent 继续使用 `query_kb``open_kb_document` 访问知识库内容。