fix(skill): 修复 skills 在文件系统的可访问性的错误并优化并发体验

This commit is contained in:
Wenjie Zhang 2026-03-26 02:29:05 +08:00
parent 0836fd8d4f
commit cea53cc67c
7 changed files with 172 additions and 38 deletions

View File

@ -69,7 +69,9 @@ class CustomCompositeBackend(CompositeBackend):
def _get_visible_skills_from_runtime(runtime) -> list[str]:
"""获取运行时可见的 skills 列表"""
context = getattr(runtime, "context", None)
selected = getattr(context, "skills", None) or []
selected = getattr(context, "_visible_skills", None)
if not isinstance(selected, list):
selected = getattr(context, "skills", None) or []
return normalize_selected_skills(selected)
@ -94,7 +96,7 @@ def create_agent_composite_backend(runtime) -> CompositeBackend:
visible_skills = _get_visible_skills_from_runtime(runtime)
thread_id = _extract_thread_id(runtime)
return CustomCompositeBackend(
default=ProvisionerSandboxBackend(thread_id=thread_id),
default=ProvisionerSandboxBackend(thread_id=thread_id, visible_skills=visible_skills),
routes={
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills),
},

View File

@ -17,6 +17,7 @@ from deepagents.backends.protocol import (
from deepagents.backends.sandbox import BaseSandbox
from yuxi import config as conf
from yuxi.services.skill_service import sync_thread_visible_skills
from yuxi.utils.logging_config import logger
from .provider import get_sandbox_provider, sandbox_id_for_thread
@ -61,11 +62,12 @@ def _looks_like_binary(content: bytes) -> bool:
class ProvisionerSandboxBackend(BaseSandbox):
def __init__(self, thread_id: str):
def __init__(self, thread_id: str, *, visible_skills: list[str] | None = None):
self._thread_id = str(thread_id or "").strip()
if not self._thread_id:
raise ValueError("thread_id is required for ProvisionerSandboxBackend")
self._visible_skills = list(visible_skills or [])
self._provider = get_sandbox_provider()
self._id = sandbox_id_for_thread(self._thread_id)
self._client: Any | None = None
@ -88,6 +90,7 @@ class ProvisionerSandboxBackend(BaseSandbox):
return AgentSandboxClient(base_url=sandbox_url, timeout=self._command_timeout_seconds)
def _get_client(self) -> Any:
sync_thread_visible_skills(self._thread_id, self._visible_skills)
connection = self._provider.get(self._thread_id, create_if_missing=True)
if connection is None:
raise RuntimeError(f"sandbox is unavailable for thread {self._thread_id}")

View File

@ -4,6 +4,7 @@ import asyncio
import re
import shutil
import tempfile
import threading
import uuid
import zipfile
from pathlib import Path, PurePosixPath
@ -52,6 +53,17 @@ TEXT_FILE_EXTENSIONS = {
}
BUILTIN_SKILL_OPERATOR = "builtin-system"
_THREAD_SKILLS_LOCK = threading.Lock()
_THREAD_SKILLS_LOCKS: dict[str, threading.Lock] = {}
def _get_thread_skills_lock(thread_id: str) -> threading.Lock:
with _THREAD_SKILLS_LOCK:
lock = _THREAD_SKILLS_LOCKS.get(thread_id)
if lock is None:
lock = threading.Lock()
_THREAD_SKILLS_LOCKS[thread_id] = lock
return lock
def _normalize_string_list(values: list[str] | None) -> list[str]:
@ -82,6 +94,69 @@ def get_skills_root_dir() -> Path:
return root
def get_thread_skills_root_dir(thread_id: str) -> Path:
safe_thread_id = str(thread_id or "").strip()
if not safe_thread_id:
raise ValueError("thread_id is required")
if not re.fullmatch(r"[A-Za-z0-9_-]+", safe_thread_id):
raise ValueError("thread_id contains invalid characters")
root = Path(sys_config.save_dir) / "threads" / safe_thread_id / "skills"
root.mkdir(parents=True, exist_ok=True)
return root
def sync_thread_visible_skills(thread_id: str, selected_slugs: list[str] | None) -> Path:
skills_root = get_skills_root_dir().resolve()
thread_skills_root = get_thread_skills_root_dir(thread_id)
normalized_slugs = [slug for slug in _normalize_string_list(selected_slugs) if is_valid_skill_slug(slug)]
visible_slugs = set(normalized_slugs)
with _get_thread_skills_lock(thread_id):
for entry in thread_skills_root.iterdir():
if entry.name in visible_slugs:
continue
if entry.is_dir() and not entry.is_symlink():
shutil.rmtree(entry)
else:
entry.unlink()
for slug in normalized_slugs:
source_dir = (skills_root / slug).resolve()
target_dir = thread_skills_root / slug
try:
source_dir.relative_to(skills_root)
except ValueError:
continue
if not source_dir.is_dir():
if target_dir.exists() or target_dir.is_symlink():
if target_dir.is_dir() and not target_dir.is_symlink():
shutil.rmtree(target_dir)
else:
target_dir.unlink()
continue
if target_dir.exists():
if target_dir.is_symlink():
target_dir.unlink()
elif target_dir.is_dir():
if _dirs_equal(target_dir, source_dir):
continue
shutil.rmtree(target_dir)
else:
target_dir.unlink()
temp_target = thread_skills_root / f".{slug}.tmp-{uuid.uuid4().hex[:8]}"
try:
shutil.copytree(source_dir, temp_target, symlinks=False)
temp_target.rename(target_dir)
finally:
if temp_target.exists():
shutil.rmtree(temp_target, ignore_errors=True)
return thread_skills_root
def get_builtin_skill_specs() -> list[Any]:
from yuxi.agents.skills.buildin import BUILTIN_SKILLS

View File

@ -26,6 +26,7 @@ def test_create_agent_composite_backend_uses_provisioner_default(monkeypatch):
backend = create_agent_composite_backend(_runtime(skills=["reporter"]))
assert isinstance(backend.default, ProvisionerSandboxBackend)
assert backend.default._visible_skills == ["reporter"]
assert "/skills/" in backend.routes

View File

@ -43,6 +43,27 @@ def test_is_valid_skill_slug():
assert svc.is_valid_skill_slug("") is False
def test_sync_thread_visible_skills_only_keeps_selected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
skills_root = tmp_path / "skills"
(skills_root / "alpha").mkdir(parents=True, exist_ok=True)
(skills_root / "alpha" / "SKILL.md").write_text("alpha", encoding="utf-8")
(skills_root / "beta").mkdir(parents=True, exist_ok=True)
(skills_root / "beta" / "SKILL.md").write_text("beta", encoding="utf-8")
thread_root = svc.sync_thread_visible_skills("thread_1", ["alpha", "missing", "alpha"])
assert thread_root == tmp_path / "threads" / "thread_1" / "skills"
assert sorted(path.name for path in thread_root.iterdir()) == ["alpha"]
assert (thread_root / "alpha").is_dir()
assert not (thread_root / "alpha").is_symlink()
assert (thread_root / "alpha" / "SKILL.md").read_text(encoding="utf-8") == "alpha"
svc.sync_thread_visible_skills("thread_1", ["beta"])
assert sorted(path.name for path in thread_root.iterdir()) == ["beta"]
assert (thread_root / "beta" / "SKILL.md").read_text(encoding="utf-8") == "beta"
@pytest.mark.asyncio
async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
# Mock get_tool_metadata to return tool list
@ -290,11 +311,7 @@ async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: p
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",
"---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n",
encoding="utf-8",
)
(source_dir / "prompts").mkdir(parents=True, exist_ok=True)
@ -387,11 +404,7 @@ async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monke
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",
"---\nname: reporter\ndescription: old\n---\n# SQL Reporter\n",
encoding="utf-8",
)

View File

@ -118,7 +118,6 @@ class LocalContainerProvisionerBackend:
)
self._network = os.getenv("DOCKER_NETWORK")
self._threads_host_path = os.getenv("DOCKER_THREADS_HOST_PATH")
self._skills_host_path = os.getenv("DOCKER_SKILLS_HOST_PATH")
self._container_prefix = os.getenv("DOCKER_SANDBOX_PREFIX", "yuxi-sandbox")
self._sandbox_host = os.getenv("DOCKER_SANDBOX_HOST", "host.docker.internal")
self._health_timeout_seconds = int(os.getenv("SANDBOX_HEALTH_TIMEOUT_SECONDS", "300"))
@ -131,7 +130,6 @@ class LocalContainerProvisionerBackend:
self._resolve_host_paths()
self._threads_host_path = self._normalize_host_bind_path(self._threads_host_path)
self._skills_host_path = self._normalize_host_bind_path(self._skills_host_path)
@staticmethod
def _normalize_host_bind_path(path_value: str | None) -> str:
@ -170,8 +168,27 @@ class LocalContainerProvisionerBackend:
def _container_name(self, sandbox_id: str) -> str:
return f"{self._container_prefix}-{self._sanitize_id(sandbox_id)}"
def _thread_skills_host_path(self, thread_id: str) -> Path:
threads_root = Path(self._threads_host_path).resolve()
thread_skills = (threads_root / thread_id / "skills").resolve()
try:
thread_skills.relative_to(threads_root)
except ValueError as exc:
raise ValueError("thread skills path resolved outside threads host root") from exc
return thread_skills
def _is_expected_skills_mount(self, container, thread_id: str) -> bool:
expected_source = str(self._thread_skills_host_path(thread_id))
for mount in container.attrs.get("Mounts") or []:
destination = (mount.get("Destination") or "").rstrip("/")
if destination != "/home/yuxi/skills":
continue
source = str(mount.get("Source") or "").rstrip("/")
return source == expected_source
return False
def _resolve_host_paths(self) -> None:
if self._threads_host_path and self._skills_host_path:
if self._threads_host_path:
return
container_id = os.getenv("HOSTNAME", "").strip()
@ -194,9 +211,6 @@ class LocalContainerProvisionerBackend:
base = Path(self._normalize_host_bind_path(saves_source))
if not self._threads_host_path:
self._threads_host_path = str(base / "threads")
if not self._skills_host_path:
self._skills_host_path = str(base / "skills")
def _host_port_for(self, container) -> int | None:
ports = (container.attrs.get("NetworkSettings") or {}).get("Ports") or {}
bindings = ports.get(f"{self._container_port}/tcp")
@ -252,6 +266,11 @@ class LocalContainerProvisionerBackend:
existing = self._get_container(sandbox_id)
if existing is not None:
existing.reload()
if not self._is_expected_skills_mount(existing, safe_thread_id):
logger.info("Recreating sandbox %s because skills mount is stale", sandbox_id)
self.delete(sandbox_id)
existing = None
if existing is not None:
if existing.status == "running":
try:
self._ensure_user_data_writable(existing)
@ -277,8 +296,8 @@ class LocalContainerProvisionerBackend:
raise ValueError("thread_id resolved outside threads host root") from exc
thread_user_data.mkdir(parents=True, exist_ok=True)
skills_path = Path(self._skills_host_path)
skills_path.mkdir(parents=True, exist_ok=True)
thread_skills = self._thread_skills_host_path(safe_thread_id)
thread_skills.mkdir(parents=True, exist_ok=True)
container_name = self._container_name(sandbox_id)
run_kwargs = {
@ -292,7 +311,7 @@ class LocalContainerProvisionerBackend:
},
"volumes": {
str(thread_user_data): {"bind": "/home/yuxi/user-data", "mode": "rw"},
str(skills_path): {"bind": "/home/yuxi/skills", "mode": "ro"},
str(thread_skills): {"bind": "/home/yuxi/skills", "mode": "ro"},
},
"ports": {f"{self._container_port}/tcp": None},
"security_opt": ["seccomp=unconfined"],
@ -318,6 +337,17 @@ class LocalContainerProvisionerBackend:
if container is None:
return None
container.reload()
thread_id = str((container.labels or {}).get("thread-id") or "").strip()
if not thread_id:
return None
safe_thread_id = self._validate_thread_id(thread_id)
if not self._is_expected_skills_mount(container, safe_thread_id):
logger.info("Discarding stale sandbox %s with legacy skills mount", sandbox_id)
try:
self.delete(sandbox_id)
except Exception as exc:
logger.warning("Failed to delete stale sandbox %s during discover: %s", sandbox_id, exc)
return None
record = self._to_record(container, sandbox_id)
if not record.sandbox_url:
return None
@ -403,16 +433,15 @@ class KubernetesProvisionerBackend:
command=["sh", "-c"],
args=[
"chmod 777 /home/yuxi "
"&& mkdir -p /home/yuxi/user-data/workspace /home/yuxi/user-data/uploads /home/yuxi/user-data/outputs "
"&& chmod -R 777 /home/yuxi/user-data ",
f"&& mkdir -p /mnt/shared-data/threads/{thread_id}/user-data/workspace "
f"/mnt/shared-data/threads/{thread_id}/user-data/uploads "
f"/mnt/shared-data/threads/{thread_id}/user-data/outputs "
f"/mnt/shared-data/threads/{thread_id}/skills "
f"&& chmod -R 777 /mnt/shared-data/threads/{thread_id}/user-data ",
],
volume_mounts=[
self._client.V1VolumeMount(name="home-dir", mount_path="/home/yuxi"),
self._client.V1VolumeMount(
name="shared-data",
mount_path="/home/yuxi/user-data",
sub_path=f"threads/{thread_id}/user-data",
),
self._client.V1VolumeMount(name="shared-data", mount_path="/mnt/shared-data"),
],
),
],
@ -431,8 +460,8 @@ class KubernetesProvisionerBackend:
self._client.V1VolumeMount(
name="shared-data",
mount_path="/home/yuxi/skills",
sub_path="skills",
read_only=False,
sub_path=f"threads/{thread_id}/skills",
read_only=True,
),
],
)

View File

@ -212,17 +212,19 @@
</a-tooltip> -->
<p class="description">{{ database.description || '暂无描述' }}</p>
<div class="tags">
<a-tag color="blue" v-if="database.embed_info?.name">{{
database.embed_info.name
}}</a-tag>
<!-- <a-tag color="green" v-if="database.embed_info?.dimension">{{ database.embed_info.dimension }}</a-tag> -->
<a-tag
:bordered="false"
:color="getKbTypeColor(database.kb_type || 'lightrag')"
class="kb-type-tag"
size="small"
>
{{ getKbTypeLabel(database.kb_type || 'lightrag') }}
</a-tag>
<!-- 保留最后一个使用 / 切分 -->
<a-tag color="blue" v-if="database.embed_info?.name"
:bordered="false">{{
database.embed_info.name.split('/').slice(-1)[0]
}}</a-tag>
</div>
<!-- <button @click="deleteDatabase(database.collection_name)">删除</button> -->
</div>
@ -675,11 +677,16 @@ onMounted(() => {
.database,
.graphbase {
background: linear-gradient(145deg, var(--gray-0) 0%, var(--gray-10) 100%);
background: linear-gradient(45deg, var(--gray-0) 0%, var(--gray-25) 100%);
box-shadow: 0px 1px 2px 0px var(--shadow-2);
border: 1px solid var(--gray-100);
transition: none;
border: 1px solid var(--gray-50);
transition: all 0.3s;
position: relative;
&:hover {
background: linear-gradient(45deg, var(--gray-0) 0%, var(--main-30) 100%);
box-shadow: 0px 1px 5px var(--shadow-3);
}
}
.dbcard,
@ -784,6 +791,10 @@ onMounted(() => {
font-weight: 400;
flex: 1;
}
.tags {
opacity: 0.8;
}
}
.database-empty {