feat(sync): 添加 Vault 所有的用户 Skill 记录
This commit is contained in:
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user