feat(sync): 添加 Vault 所有的用户 Skill 记录
This commit is contained in:
@@ -96,11 +96,21 @@ class AgentRuntime:
|
||||
provider = self.providers.get(request.provider_id)
|
||||
skill_config = None
|
||||
if request.skill_id:
|
||||
if self.skills is None:
|
||||
raise RuntimeError("Skill Runtime is not configured.")
|
||||
skill_config = self.skills.build_agent_configuration(
|
||||
request.skill_id, provider.config.capabilities
|
||||
)
|
||||
if request.skill_id.startswith("user_skill_"):
|
||||
from app.services.user_skills import build_agent_configuration
|
||||
|
||||
skill_config = await asyncio.to_thread(
|
||||
build_agent_configuration,
|
||||
request.skill_id,
|
||||
provider.config.capabilities,
|
||||
self.tools,
|
||||
)
|
||||
else:
|
||||
if self.skills is None:
|
||||
raise RuntimeError("Skill Runtime is not configured.")
|
||||
skill_config = self.skills.build_agent_configuration(
|
||||
request.skill_id, provider.config.capabilities
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
run = AgentRun(
|
||||
run_id=f"run_{uuid4().hex}",
|
||||
|
||||
@@ -502,6 +502,83 @@ class SkillListResponse(Contract):
|
||||
items: list[Skill] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserSkillData(Contract):
|
||||
version: int = Field(ge=1, le=9007199254740991)
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
prompt: str = Field(default="", max_length=64000)
|
||||
tools: list[str] = Field(default_factory=list, max_length=64)
|
||||
permissions: list[str] = Field(default_factory=list, max_length=32)
|
||||
retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
|
||||
required_capabilities: list[ModelCapability] = Field(default_factory=list, max_length=16)
|
||||
created_at_ms: int = Field(ge=0, le=253402300799999)
|
||||
updated_at_ms: int = Field(ge=0, le=253402300799999)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def user_skill_name_not_blank(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("name must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("tools", "permissions")
|
||||
@classmethod
|
||||
def user_skill_identifiers(cls, values: list[str]) -> list[str]:
|
||||
if len(values) != len(set(values)):
|
||||
raise ValueError("identifiers must be unique")
|
||||
if any(
|
||||
not value
|
||||
or len(value) > 128
|
||||
or any(not (char.isascii() and (char.isalnum() or char in "._-")) for char in value)
|
||||
for value in values
|
||||
):
|
||||
raise ValueError("identifier is invalid")
|
||||
return values
|
||||
|
||||
@model_validator(mode="after")
|
||||
def user_skill_timestamps(self):
|
||||
if self.updated_at_ms < self.created_at_ms:
|
||||
raise ValueError("updated_at_ms precedes created_at_ms")
|
||||
return self
|
||||
|
||||
|
||||
class UserSkillWriteRequest(Contract):
|
||||
revision: str = Field(default="", pattern=r"^(?:[0-9a-f]{64})?$")
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
prompt: str = Field(default="", max_length=64000)
|
||||
tools: list[str] = Field(default_factory=list, max_length=64)
|
||||
permissions: list[str] = Field(default_factory=list, max_length=32)
|
||||
retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
|
||||
required_capabilities: list[ModelCapability] = Field(default_factory=list, max_length=16)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def user_skill_write_name_not_blank(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("name must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("tools", "permissions")
|
||||
@classmethod
|
||||
def user_skill_write_identifiers(cls, values: list[str]) -> list[str]:
|
||||
return UserSkillData.user_skill_identifiers(values)
|
||||
|
||||
|
||||
class UserSkill(Contract):
|
||||
skill_id: str = Field(pattern=r"^user_skill_[0-9a-f]{32}$")
|
||||
revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
data: UserSkillData
|
||||
status: Literal["ready", "dependency_missing", "permission_required"]
|
||||
missing_dependencies: list[str] = Field(default_factory=list)
|
||||
undeclared_permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserSkillListResponse(Contract):
|
||||
items: list[UserSkill] = Field(default_factory=list)
|
||||
page: PageMeta = Field(default_factory=PageMeta)
|
||||
|
||||
|
||||
class ExtensionInstallRequest(Contract):
|
||||
package_path: str
|
||||
|
||||
|
||||
@@ -100,6 +100,12 @@ class SkillRuntime:
|
||||
except ValidationError as exc:
|
||||
raise _manifest_error("skill", exc) from exc
|
||||
_validate_id("skill", manifest.skill_id)
|
||||
if manifest.skill_id.startswith("user_skill_"):
|
||||
raise ExtensionError(
|
||||
"SKILL_ID_RESERVED",
|
||||
"The user_skill_ prefix is reserved for Vault-owned user Skills.",
|
||||
status_code=422,
|
||||
)
|
||||
_validate_permissions("skill", manifest.permissions)
|
||||
if manifest.skill_id in self._records:
|
||||
raise ExtensionError(
|
||||
|
||||
@@ -95,6 +95,9 @@ from app.contracts import (
|
||||
SearchResponse,
|
||||
Skill,
|
||||
SkillListResponse,
|
||||
UserSkill,
|
||||
UserSkillListResponse,
|
||||
UserSkillWriteRequest,
|
||||
Task,
|
||||
TaskCreateRequest,
|
||||
TaskListResponse,
|
||||
@@ -677,6 +680,52 @@ async def list_tools() -> ToolListResponse:
|
||||
|
||||
|
||||
# Skills
|
||||
@router.get("/user-skills", response_model=UserSkillListResponse, tags=["Skills"])
|
||||
async def list_user_skills(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> UserSkillListResponse:
|
||||
from app.services.user_skills import list_user_skills as list_records
|
||||
|
||||
items, total = await asyncio.to_thread(
|
||||
list_records, container.tools, limit=limit, offset=offset
|
||||
)
|
||||
return UserSkillListResponse(
|
||||
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/user-skills/{skill_id}", response_model=UserSkill, tags=["Skills"])
|
||||
async def get_user_skill(skill_id: str) -> UserSkill:
|
||||
from app.services.user_skills import get_user_skill as get_record
|
||||
|
||||
return await asyncio.to_thread(get_record, skill_id, container.tools)
|
||||
|
||||
|
||||
@router.post("/user-skills", response_model=UserSkill, status_code=201, tags=["Skills"])
|
||||
async def create_user_skill(request: UserSkillWriteRequest) -> UserSkill:
|
||||
from app.services.user_skills import create_user_skill as create_record
|
||||
|
||||
return await asyncio.to_thread(create_record, request, container.tools)
|
||||
|
||||
|
||||
@router.put("/user-skills/{skill_id}", response_model=UserSkill, tags=["Skills"])
|
||||
async def update_user_skill(skill_id: str, request: UserSkillWriteRequest) -> UserSkill:
|
||||
from app.services.user_skills import update_user_skill as update_record
|
||||
|
||||
return await asyncio.to_thread(update_record, skill_id, request, container.tools)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/user-skills/{skill_id}", response_model=OperationResponse, tags=["Skills"]
|
||||
)
|
||||
async def delete_user_skill(skill_id: str, revision: str = Query()) -> OperationResponse:
|
||||
from app.services.user_skills import delete_user_skill as delete_record
|
||||
|
||||
await asyncio.to_thread(delete_record, skill_id, revision)
|
||||
return OperationResponse(status="completed", resource_id=skill_id, message="deleted")
|
||||
|
||||
|
||||
@router.get("/skills", response_model=SkillListResponse, tags=["Skills"])
|
||||
async def list_skills() -> SkillListResponse:
|
||||
return SkillListResponse(items=container.skills.list())
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Vault-owned user Skill records and their declarative Agent configuration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from time import time_ns
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app import host_bridge
|
||||
from app.agent.permissions import KNOWN_PERMISSIONS
|
||||
from app.contracts import ModelCapability, UserSkill, UserSkillData, UserSkillWriteRequest
|
||||
from app.errors import ApiError
|
||||
from app.extensions.runtime import AgentConfiguration
|
||||
from app.services.desktop_notes import call
|
||||
|
||||
|
||||
def _operation_id() -> str:
|
||||
return host_bridge.operation_id.get() or str(uuid4())
|
||||
|
||||
|
||||
def _validate_skill_id(skill_id: str) -> None:
|
||||
if not (
|
||||
skill_id.startswith("user_skill_")
|
||||
and len(skill_id) == 43
|
||||
and all(char in "0123456789abcdef" for char in skill_id[11:])
|
||||
):
|
||||
raise ApiError(422, "USER_SKILL_ID_INVALID", "用户 Skill 标识无效。")
|
||||
|
||||
|
||||
def _validate_declarations(request: UserSkillWriteRequest) -> None:
|
||||
unknown = sorted(set(request.permissions) - KNOWN_PERMISSIONS)
|
||||
if unknown:
|
||||
raise ApiError(
|
||||
422,
|
||||
"USER_SKILL_PERMISSION_UNKNOWN",
|
||||
"用户 Skill 声明了未知权限。",
|
||||
{"permissions": unknown},
|
||||
)
|
||||
|
||||
|
||||
def _state(data: UserSkillData, tools) -> tuple[str, list[str], list[str]]:
|
||||
missing = [name for name in data.tools if not tools.contains(name)]
|
||||
declared = set(data.permissions)
|
||||
required = {
|
||||
tools.get(name).definition.permission
|
||||
for name in data.tools
|
||||
if tools.contains(name) and tools.get(name).definition.permission
|
||||
}
|
||||
undeclared = sorted(permission for permission in required - declared if permission)
|
||||
status = "dependency_missing" if missing else "permission_required" if undeclared else "ready"
|
||||
return status, missing, undeclared
|
||||
|
||||
|
||||
def _public(document: dict, tools) -> UserSkill:
|
||||
data = UserSkillData.model_validate(document["record"]["data"])
|
||||
status, missing, undeclared = _state(data, tools)
|
||||
return UserSkill(
|
||||
skill_id=document["record"]["id"],
|
||||
revision=document["hash"],
|
||||
data=data,
|
||||
status=status,
|
||||
missing_dependencies=missing,
|
||||
undeclared_permissions=undeclared,
|
||||
)
|
||||
|
||||
|
||||
def _request_values(request: UserSkillWriteRequest) -> dict:
|
||||
return request.model_dump(exclude={"revision"}, mode="json")
|
||||
|
||||
|
||||
def _replay(operation_id: str, skill_id: str, request: UserSkillWriteRequest | None, expected: str):
|
||||
receipt = call("user_skills.operation", operation_id=operation_id)
|
||||
if receipt is None:
|
||||
return None
|
||||
data = receipt.get("record", {}).get("data", {})
|
||||
requested = {} if request is None else _request_values(request)
|
||||
mismatched_fields = sorted(
|
||||
key for key, value in requested.items() if data.get(key) != value
|
||||
)
|
||||
matches = (
|
||||
receipt.get("record", {}).get("kind") == "user_skill"
|
||||
and receipt.get("record", {}).get("id") == skill_id
|
||||
and receipt.get("expected") == expected
|
||||
and receipt.get("deleted") is (request is None)
|
||||
and not mismatched_fields
|
||||
)
|
||||
if not matches:
|
||||
raise ApiError(
|
||||
409,
|
||||
"USER_SKILL_OPERATION_CONFLICT",
|
||||
"该幂等键已用于不同的用户 Skill 操作。",
|
||||
{
|
||||
"kind_matches": receipt.get("record", {}).get("kind") == "user_skill",
|
||||
"id_matches": receipt.get("record", {}).get("id") == skill_id,
|
||||
"expected_matches": receipt.get("expected") == expected,
|
||||
"operation_matches": receipt.get("deleted") is (request is None),
|
||||
"mismatched_fields": mismatched_fields,
|
||||
},
|
||||
)
|
||||
return receipt if request is not None else True
|
||||
|
||||
|
||||
def list_user_skills(tools, *, limit: int, offset: int) -> tuple[list[UserSkill], int]:
|
||||
page = call("user_skills.list", offset=offset, limit=limit)
|
||||
return [_public(item, tools) for item in page["items"]], page["total"]
|
||||
|
||||
|
||||
def get_user_skill(skill_id: str, tools) -> UserSkill:
|
||||
_validate_skill_id(skill_id)
|
||||
document = call("user_skills.get", id=skill_id)
|
||||
if document is None:
|
||||
raise ApiError(404, "USER_SKILL_NOT_FOUND", "用户 Skill 不存在。", {"skill_id": skill_id})
|
||||
return _public(document, tools)
|
||||
|
||||
|
||||
def create_user_skill(request: UserSkillWriteRequest, tools) -> UserSkill:
|
||||
_validate_declarations(request)
|
||||
if request.revision:
|
||||
raise ApiError(422, "USER_SKILL_REVISION_INVALID", "新建用户 Skill 时 revision 必须为空。")
|
||||
operation_id = _operation_id()
|
||||
skill_id = f"user_skill_{UUID(operation_id).hex}"
|
||||
if replay := _replay(operation_id, skill_id, request, ""):
|
||||
return _public(replay, tools)
|
||||
now = time_ns() // 1_000_000
|
||||
data = UserSkillData(
|
||||
version=1,
|
||||
created_at_ms=now,
|
||||
updated_at_ms=now,
|
||||
**request.model_dump(exclude={"revision"}),
|
||||
)
|
||||
document = call(
|
||||
"user_skills.write",
|
||||
record={"schema": 1, "kind": "user_skill", "id": skill_id, "data": data.model_dump(mode="json")},
|
||||
expected="",
|
||||
operation_id=operation_id,
|
||||
)
|
||||
return _public(document, tools)
|
||||
|
||||
|
||||
def update_user_skill(skill_id: str, request: UserSkillWriteRequest, tools) -> UserSkill:
|
||||
_validate_skill_id(skill_id)
|
||||
_validate_declarations(request)
|
||||
if not request.revision:
|
||||
raise ApiError(422, "USER_SKILL_REVISION_REQUIRED", "更新用户 Skill 需要当前 revision。")
|
||||
operation_id = _operation_id()
|
||||
if replay := _replay(operation_id, skill_id, request, request.revision):
|
||||
return _public(replay, tools)
|
||||
current = get_user_skill(skill_id, tools)
|
||||
data = UserSkillData(
|
||||
version=current.data.version + 1,
|
||||
created_at_ms=current.data.created_at_ms,
|
||||
updated_at_ms=max(time_ns() // 1_000_000, current.data.updated_at_ms),
|
||||
**request.model_dump(exclude={"revision"}),
|
||||
)
|
||||
try:
|
||||
document = call(
|
||||
"user_skills.write",
|
||||
record={"schema": 1, "kind": "user_skill", "id": skill_id, "data": data.model_dump(mode="json")},
|
||||
expected=request.revision,
|
||||
operation_id=operation_id,
|
||||
)
|
||||
except ApiError as error:
|
||||
if error.code == "REVISION_CONFLICT":
|
||||
raise ApiError(409, "USER_SKILL_REVISION_CONFLICT", "用户 Skill 已被其他设备修改,请重新加载。") from None
|
||||
raise
|
||||
return _public(document, tools)
|
||||
|
||||
|
||||
def delete_user_skill(skill_id: str, revision: str) -> None:
|
||||
_validate_skill_id(skill_id)
|
||||
if len(revision) != 64 or any(char not in "0123456789abcdef" for char in revision):
|
||||
raise ApiError(422, "USER_SKILL_REVISION_INVALID", "删除用户 Skill 需要当前 revision。")
|
||||
operation_id = _operation_id()
|
||||
if _replay(operation_id, skill_id, None, revision):
|
||||
return
|
||||
try:
|
||||
call("user_skills.delete", id=skill_id, expected=revision, operation_id=operation_id)
|
||||
except ApiError as error:
|
||||
if error.code == "REVISION_CONFLICT":
|
||||
raise ApiError(409, "USER_SKILL_REVISION_CONFLICT", "用户 Skill 已被其他设备修改,请重新加载。") from None
|
||||
raise
|
||||
|
||||
|
||||
def build_agent_configuration(skill_id: str, provider_capabilities: list[ModelCapability], tools) -> AgentConfiguration:
|
||||
skill = get_user_skill(skill_id, tools)
|
||||
if skill.status != "ready":
|
||||
raise ApiError(
|
||||
409,
|
||||
"USER_SKILL_NOT_READY",
|
||||
"用户 Skill 的工具或权限声明尚未满足。",
|
||||
{
|
||||
"skill_id": skill_id,
|
||||
"missing_dependencies": skill.missing_dependencies,
|
||||
"undeclared_permissions": skill.undeclared_permissions,
|
||||
},
|
||||
)
|
||||
missing = sorted(
|
||||
capability.value
|
||||
for capability in set(skill.data.required_capabilities) - set(provider_capabilities)
|
||||
)
|
||||
if missing:
|
||||
raise ApiError(
|
||||
409,
|
||||
"USER_SKILL_MODEL_CAPABILITY_MISSING",
|
||||
"当前模型不满足用户 Skill 的能力要求。",
|
||||
{"skill_id": skill_id, "missing_capabilities": missing},
|
||||
)
|
||||
return AgentConfiguration(
|
||||
skill_id=skill_id,
|
||||
system_prompt=skill.data.prompt,
|
||||
allowed_tools=list(skill.data.tools),
|
||||
permissions=list(skill.data.permissions),
|
||||
retrieval=skill.data.retrieval.model_copy(deep=True),
|
||||
)
|
||||
+16
-3
@@ -18,6 +18,9 @@ import threading
|
||||
|
||||
PROTOCOL = 1
|
||||
MAX_BOOTSTRAP = 16384
|
||||
UUID_PATTERN = re.compile(
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
)
|
||||
|
||||
|
||||
def bootstrap(line: bytes) -> dict:
|
||||
@@ -79,9 +82,19 @@ class SessionAuth:
|
||||
return
|
||||
from app import host_bridge
|
||||
vault = single(b"x-opennexus-vault").decode("ascii", errors="replace")
|
||||
token = host_bridge.vault_id.set(vault if re.fullmatch(r"[0-9a-f-]{36}", vault) else None)
|
||||
operation = single(b"x-request-id").decode("ascii", errors="replace")
|
||||
operation_token = host_bridge.operation_id.set(operation if re.fullmatch(r"[0-9a-f-]{36}", operation) else None)
|
||||
token = host_bridge.vault_id.set(vault if UUID_PATTERN.fullmatch(vault) else None)
|
||||
# Mutating clients may retain a UUID across an ambiguous response. Other
|
||||
# endpoint-specific idempotency tokens remain available to the route but
|
||||
# do not enter the Host journal unless they are valid operation UUIDs.
|
||||
idempotency = single(b"idempotency-key").decode("ascii", errors="replace")
|
||||
operation = (
|
||||
idempotency
|
||||
if UUID_PATTERN.fullmatch(idempotency)
|
||||
else single(b"x-request-id").decode("ascii", errors="replace")
|
||||
)
|
||||
operation_token = host_bridge.operation_id.set(
|
||||
operation if UUID_PATTERN.fullmatch(operation) else None
|
||||
)
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user