agskillmanager 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agskillmanager-1.0.0.dist-info/METADATA +168 -0
- agskillmanager-1.0.0.dist-info/RECORD +24 -0
- agskillmanager-1.0.0.dist-info/WHEEL +4 -0
- agskillmanager-1.0.0.dist-info/licenses/LICENSE +202 -0
- skill_manager/__init__.py +137 -0
- skill_manager/_optional.py +18 -0
- skill_manager/_xai.py +48 -0
- skill_manager/api.py +965 -0
- skill_manager/config.py +33 -0
- skill_manager/discovery.py +345 -0
- skill_manager/errors.py +83 -0
- skill_manager/generation.py +83 -0
- skill_manager/integrations/__init__.py +1 -0
- skill_manager/integrations/langchain.py +130 -0
- skill_manager/models.py +307 -0
- skill_manager/observability.py +67 -0
- skill_manager/otel.py +107 -0
- skill_manager/postgres.py +318 -0
- skill_manager/py.typed +0 -0
- skill_manager/registry.py +195 -0
- skill_manager/resolution.py +187 -0
- skill_manager/sources.py +175 -0
- skill_manager/testing.py +362 -0
- skill_manager/validation.py +95 -0
skill_manager/testing.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Conformance tests for `SkillSource` and `SkillRegistry` implementations.
|
|
2
|
+
|
|
3
|
+
Subclass a contract in a test module and implement its factory; your test
|
|
4
|
+
runner collects one test per promise of the contract:
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from skill_manager.testing import SkillRegistryContract
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestMyRegistry(SkillRegistryContract):
|
|
11
|
+
async def make_registry(self):
|
|
12
|
+
registry = MyRegistry(dsn)
|
|
13
|
+
await registry.truncate() # every test starts from an empty catalog
|
|
14
|
+
return registry
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Each test calls ``aclose`` on the object it made. The tests are plain
|
|
18
|
+
synchronous methods that raise `AssertionError` with a descriptive message, so
|
|
19
|
+
they run under pytest, or under unittest when the subclass also inherits
|
|
20
|
+
``unittest.TestCase``. Neither pytest nor an async plugin is required. Set
|
|
21
|
+
``loop_factory`` to choose the event loop, for example
|
|
22
|
+
``asyncio.SelectorEventLoop`` for psycopg on Windows.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
from abc import ABC, abstractmethod
|
|
29
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
30
|
+
|
|
31
|
+
from skill_manager.errors import SkillConflictError, SkillNotFoundError
|
|
32
|
+
from skill_manager.models import (
|
|
33
|
+
Skill,
|
|
34
|
+
SkillLifecycle,
|
|
35
|
+
SkillLifecycleState,
|
|
36
|
+
SkillMetadata,
|
|
37
|
+
)
|
|
38
|
+
from skill_manager.validation import parse_skill_yaml
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
42
|
+
|
|
43
|
+
from skill_manager.registry import SkillRegistry
|
|
44
|
+
from skill_manager.sources import SkillSource
|
|
45
|
+
|
|
46
|
+
__all__ = ["SkillRegistryContract", "SkillSourceContract"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _skill(
|
|
50
|
+
name: str = "contract-skill",
|
|
51
|
+
version: str = "1.0.0",
|
|
52
|
+
*,
|
|
53
|
+
state: SkillLifecycleState = SkillLifecycleState.ACTIVE,
|
|
54
|
+
) -> Skill:
|
|
55
|
+
return Skill(
|
|
56
|
+
metadata=SkillMetadata(name=name, version=version, description="Contract Skill."),
|
|
57
|
+
instructions=("Follow the contract.",),
|
|
58
|
+
lifecycle=SkillLifecycle(state=state),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check(condition: bool, message: str) -> None:
|
|
63
|
+
if not condition:
|
|
64
|
+
raise AssertionError(message)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _raises(error: type[Exception], operation: Awaitable[Any], message: str) -> None:
|
|
68
|
+
try:
|
|
69
|
+
await operation
|
|
70
|
+
except error:
|
|
71
|
+
return
|
|
72
|
+
raise AssertionError(message)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Contract:
|
|
76
|
+
loop_factory: ClassVar[Callable[[], asyncio.AbstractEventLoop] | None] = None
|
|
77
|
+
|
|
78
|
+
def _run(self, coroutine: Coroutine[Any, Any, None]) -> None:
|
|
79
|
+
asyncio.run(coroutine, loop_factory=self.loop_factory)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SkillSourceContract(_Contract, ABC):
|
|
83
|
+
"""Promises every `SkillSource` keeps."""
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
async def make_source(self) -> SkillSource:
|
|
87
|
+
"""Return a new, empty source."""
|
|
88
|
+
|
|
89
|
+
def _with_source(self, test: Callable[[SkillSource], Awaitable[None]]) -> None:
|
|
90
|
+
async def run() -> None:
|
|
91
|
+
source = await self.make_source()
|
|
92
|
+
try:
|
|
93
|
+
await test(source)
|
|
94
|
+
finally:
|
|
95
|
+
await source.aclose()
|
|
96
|
+
|
|
97
|
+
self._run(run())
|
|
98
|
+
|
|
99
|
+
def test_source_id_is_non_empty(self) -> None:
|
|
100
|
+
"""The source has a non-blank identifier."""
|
|
101
|
+
|
|
102
|
+
async def test(source: SkillSource) -> None:
|
|
103
|
+
_check(bool(source.source_id.strip()), "source_id must not be empty")
|
|
104
|
+
|
|
105
|
+
self._with_source(test)
|
|
106
|
+
|
|
107
|
+
def test_empty_source_loads_nothing(self) -> None:
|
|
108
|
+
"""A new source loads no documents."""
|
|
109
|
+
|
|
110
|
+
async def test(source: SkillSource) -> None:
|
|
111
|
+
_check(not await source.load(), "a new source must load no documents")
|
|
112
|
+
|
|
113
|
+
self._with_source(test)
|
|
114
|
+
|
|
115
|
+
def test_saved_skill_round_trips(self) -> None:
|
|
116
|
+
"""A saved Skill loads back as an equal Skill."""
|
|
117
|
+
|
|
118
|
+
async def test(source: SkillSource) -> None:
|
|
119
|
+
skill = _skill()
|
|
120
|
+
await source.save(skill)
|
|
121
|
+
documents = list((await source.load()).values())
|
|
122
|
+
_check(len(documents) == 1, "load must return exactly the saved document")
|
|
123
|
+
_check(
|
|
124
|
+
await parse_skill_yaml(documents[0]) == skill,
|
|
125
|
+
"the loaded document must parse back to the saved Skill",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
self._with_source(test)
|
|
129
|
+
|
|
130
|
+
def test_save_replaces_the_previous_document(self) -> None:
|
|
131
|
+
"""Saving a Skill again replaces its document."""
|
|
132
|
+
|
|
133
|
+
async def test(source: SkillSource) -> None:
|
|
134
|
+
pending = _skill(state=SkillLifecycleState.PENDING_APPROVAL)
|
|
135
|
+
active = pending.model_copy(update={"lifecycle": SkillLifecycle()})
|
|
136
|
+
await source.save(pending)
|
|
137
|
+
await source.save(active)
|
|
138
|
+
documents = list((await source.load()).values())
|
|
139
|
+
_check(len(documents) == 1, "saving the same Skill twice must keep one document")
|
|
140
|
+
_check(
|
|
141
|
+
await parse_skill_yaml(documents[0]) == active,
|
|
142
|
+
"the latest save must win",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
self._with_source(test)
|
|
146
|
+
|
|
147
|
+
def test_delete_removes_the_document(self) -> None:
|
|
148
|
+
"""Delete removes only the given Skill."""
|
|
149
|
+
|
|
150
|
+
async def test(source: SkillSource) -> None:
|
|
151
|
+
kept, deleted = _skill("kept-skill"), _skill("deleted-skill")
|
|
152
|
+
await source.save(kept)
|
|
153
|
+
await source.save(deleted)
|
|
154
|
+
await source.delete(deleted.skill_id)
|
|
155
|
+
documents = [await parse_skill_yaml(text) for text in (await source.load()).values()]
|
|
156
|
+
_check(documents == [kept], "delete must remove only the given Skill")
|
|
157
|
+
|
|
158
|
+
self._with_source(test)
|
|
159
|
+
|
|
160
|
+
def test_delete_ignores_unknown_ids(self) -> None:
|
|
161
|
+
"""Deleting an unknown Skill is a no-op."""
|
|
162
|
+
|
|
163
|
+
async def test(source: SkillSource) -> None:
|
|
164
|
+
await source.delete(_skill().skill_id)
|
|
165
|
+
|
|
166
|
+
self._with_source(test)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class SkillRegistryContract(_Contract, ABC):
|
|
170
|
+
"""Promises every `SkillRegistry` keeps."""
|
|
171
|
+
|
|
172
|
+
@abstractmethod
|
|
173
|
+
async def make_registry(self) -> SkillRegistry:
|
|
174
|
+
"""Return a new, empty registry."""
|
|
175
|
+
|
|
176
|
+
def _with_registry(self, test: Callable[[SkillRegistry], Awaitable[None]]) -> None:
|
|
177
|
+
async def run() -> None:
|
|
178
|
+
registry = await self.make_registry()
|
|
179
|
+
try:
|
|
180
|
+
await test(registry)
|
|
181
|
+
finally:
|
|
182
|
+
await registry.aclose()
|
|
183
|
+
|
|
184
|
+
self._run(run())
|
|
185
|
+
|
|
186
|
+
def test_put_then_get(self) -> None:
|
|
187
|
+
"""A stored Skill is returned by ``get``."""
|
|
188
|
+
|
|
189
|
+
async def test(registry: SkillRegistry) -> None:
|
|
190
|
+
skill = _skill()
|
|
191
|
+
await registry.put(skill)
|
|
192
|
+
_check(await registry.get(skill.skill_id) == skill, "get must return the stored Skill")
|
|
193
|
+
|
|
194
|
+
self._with_registry(test)
|
|
195
|
+
|
|
196
|
+
def test_get_unknown_raises_not_found(self) -> None:
|
|
197
|
+
"""``get`` raises `SkillNotFoundError` for an unknown ID."""
|
|
198
|
+
|
|
199
|
+
async def test(registry: SkillRegistry) -> None:
|
|
200
|
+
await _raises(
|
|
201
|
+
SkillNotFoundError,
|
|
202
|
+
registry.get(_skill().skill_id),
|
|
203
|
+
"get must raise SkillNotFoundError for an unknown ID",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
self._with_registry(test)
|
|
207
|
+
|
|
208
|
+
def test_identical_put_is_idempotent(self) -> None:
|
|
209
|
+
"""Storing the same Skill twice keeps one copy."""
|
|
210
|
+
|
|
211
|
+
async def test(registry: SkillRegistry) -> None:
|
|
212
|
+
skill = _skill()
|
|
213
|
+
await registry.put(skill)
|
|
214
|
+
await registry.put(skill)
|
|
215
|
+
_check(len(await registry.all()) == 1, "an identical put must not duplicate")
|
|
216
|
+
|
|
217
|
+
self._with_registry(test)
|
|
218
|
+
|
|
219
|
+
def test_revision_is_immutable(self) -> None:
|
|
220
|
+
"""A stored revision cannot change content."""
|
|
221
|
+
|
|
222
|
+
async def test(registry: SkillRegistry) -> None:
|
|
223
|
+
skill = _skill()
|
|
224
|
+
await registry.put(skill)
|
|
225
|
+
changed = skill.model_copy(update={"instructions": ("Changed.",)})
|
|
226
|
+
await _raises(
|
|
227
|
+
SkillConflictError,
|
|
228
|
+
registry.put(changed),
|
|
229
|
+
"rewriting a revision with different content must conflict",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
self._with_registry(test)
|
|
233
|
+
|
|
234
|
+
def test_stale_revision_conflicts(self) -> None:
|
|
235
|
+
"""An older revision cannot replace a newer one."""
|
|
236
|
+
|
|
237
|
+
async def test(registry: SkillRegistry) -> None:
|
|
238
|
+
skill = _skill()
|
|
239
|
+
await registry.put(skill.model_copy(update={"revision": 2}))
|
|
240
|
+
await _raises(
|
|
241
|
+
SkillConflictError, registry.put(skill), "an older revision must conflict"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
self._with_registry(test)
|
|
245
|
+
|
|
246
|
+
def test_one_active_revision_per_name_and_version(self) -> None:
|
|
247
|
+
"""Only one revision per name and version is ACTIVE."""
|
|
248
|
+
|
|
249
|
+
async def test(registry: SkillRegistry) -> None:
|
|
250
|
+
await registry.put(_skill())
|
|
251
|
+
await _raises(
|
|
252
|
+
SkillConflictError,
|
|
253
|
+
registry.put(_skill()),
|
|
254
|
+
"a second ACTIVE revision of the same name and version must conflict",
|
|
255
|
+
)
|
|
256
|
+
await _raises(
|
|
257
|
+
SkillConflictError,
|
|
258
|
+
registry.check_put(_skill()),
|
|
259
|
+
"check_put must report the same conflict",
|
|
260
|
+
)
|
|
261
|
+
await registry.put(_skill(state=SkillLifecycleState.PENDING_APPROVAL))
|
|
262
|
+
|
|
263
|
+
self._with_registry(test)
|
|
264
|
+
|
|
265
|
+
def test_check_put_does_not_write(self) -> None:
|
|
266
|
+
"""``check_put`` validates without storing."""
|
|
267
|
+
|
|
268
|
+
async def test(registry: SkillRegistry) -> None:
|
|
269
|
+
await registry.check_put(_skill())
|
|
270
|
+
_check(not await registry.all(), "check_put must not store the Skill")
|
|
271
|
+
|
|
272
|
+
self._with_registry(test)
|
|
273
|
+
|
|
274
|
+
def test_by_name_returns_highest_active_version(self) -> None:
|
|
275
|
+
"""``by_name`` resolves the highest ACTIVE version."""
|
|
276
|
+
|
|
277
|
+
async def test(registry: SkillRegistry) -> None:
|
|
278
|
+
old, new = _skill(version="1.2.0"), _skill(version="1.10.0")
|
|
279
|
+
await registry.put(old)
|
|
280
|
+
await registry.put(new)
|
|
281
|
+
await registry.put(_skill(version="2.0.0", state=SkillLifecycleState.REJECTED))
|
|
282
|
+
_check(await registry.by_name(old.metadata.name) == new, "by_name must pick 1.10.0")
|
|
283
|
+
_check(
|
|
284
|
+
await registry.by_name(old.metadata.name, "1.2.0") == old,
|
|
285
|
+
"by_name with a version must return that version",
|
|
286
|
+
)
|
|
287
|
+
await _raises(
|
|
288
|
+
SkillNotFoundError,
|
|
289
|
+
registry.by_name(old.metadata.name, "2.0.0"),
|
|
290
|
+
"by_name must ignore inactive revisions",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
self._with_registry(test)
|
|
294
|
+
|
|
295
|
+
def test_all_filters_by_state(self) -> None:
|
|
296
|
+
"""``all`` returns every Skill, optionally filtered by state."""
|
|
297
|
+
|
|
298
|
+
async def test(registry: SkillRegistry) -> None:
|
|
299
|
+
active = _skill("active-skill")
|
|
300
|
+
pending = _skill("pending-skill", state=SkillLifecycleState.PENDING_APPROVAL)
|
|
301
|
+
await registry.put(active)
|
|
302
|
+
await registry.put(pending)
|
|
303
|
+
everything = {skill.skill_id: skill for skill in await registry.all()}
|
|
304
|
+
_check(
|
|
305
|
+
everything == {active.skill_id: active, pending.skill_id: pending},
|
|
306
|
+
"all must return everything",
|
|
307
|
+
)
|
|
308
|
+
_check(
|
|
309
|
+
await registry.all(states={SkillLifecycleState.PENDING_APPROVAL}) == (pending,),
|
|
310
|
+
"all must filter by state",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
self._with_registry(test)
|
|
314
|
+
|
|
315
|
+
def test_remove(self) -> None:
|
|
316
|
+
"""``remove`` deletes and returns the Skill."""
|
|
317
|
+
|
|
318
|
+
async def test(registry: SkillRegistry) -> None:
|
|
319
|
+
skill = _skill()
|
|
320
|
+
await registry.put(skill)
|
|
321
|
+
_check(await registry.remove(skill.skill_id) == skill, "remove must return the Skill")
|
|
322
|
+
await _raises(
|
|
323
|
+
SkillNotFoundError,
|
|
324
|
+
registry.remove(skill.skill_id),
|
|
325
|
+
"remove must raise SkillNotFoundError for an unknown ID",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
self._with_registry(test)
|
|
329
|
+
|
|
330
|
+
def test_replace_lifecycle(self) -> None:
|
|
331
|
+
"""``replace_lifecycle`` persists a lifecycle transition."""
|
|
332
|
+
|
|
333
|
+
async def test(registry: SkillRegistry) -> None:
|
|
334
|
+
pending = _skill(state=SkillLifecycleState.PENDING_APPROVAL)
|
|
335
|
+
await registry.put(pending)
|
|
336
|
+
active = pending.model_copy(update={"lifecycle": SkillLifecycle()})
|
|
337
|
+
await registry.replace_lifecycle(active)
|
|
338
|
+
_check(await registry.get(pending.skill_id) == active, "the transition must persist")
|
|
339
|
+
await _raises(
|
|
340
|
+
SkillNotFoundError,
|
|
341
|
+
registry.replace_lifecycle(_skill("unknown-skill")),
|
|
342
|
+
"replace_lifecycle must raise SkillNotFoundError for an unknown ID",
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
self._with_registry(test)
|
|
346
|
+
|
|
347
|
+
def test_replace_lifecycle_keeps_one_active_revision(self) -> None:
|
|
348
|
+
"""Activation cannot create a second ACTIVE revision."""
|
|
349
|
+
|
|
350
|
+
async def test(registry: SkillRegistry) -> None:
|
|
351
|
+
await registry.put(_skill())
|
|
352
|
+
pending = _skill(state=SkillLifecycleState.PENDING_APPROVAL)
|
|
353
|
+
await registry.put(pending)
|
|
354
|
+
await _raises(
|
|
355
|
+
SkillConflictError,
|
|
356
|
+
registry.replace_lifecycle(
|
|
357
|
+
pending.model_copy(update={"lifecycle": SkillLifecycle()})
|
|
358
|
+
),
|
|
359
|
+
"activating a second revision must conflict",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
self._with_registry(test)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Safe YAML parsing and Skill domain validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from skill_manager.errors import SkillValidationError
|
|
13
|
+
from skill_manager.models import Skill, SkillLifecycleState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SkillValidator(ABC):
|
|
17
|
+
"""Extension point for application-specific Skill validation.
|
|
18
|
+
|
|
19
|
+
`SkillManager` validates every Skill it registers or loads. Subclass
|
|
20
|
+
`DefaultSkillValidator` and call ``super().validate`` to keep the built-in
|
|
21
|
+
invariants while adding your own.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
```python
|
|
25
|
+
class RequireTags(DefaultSkillValidator):
|
|
26
|
+
async def validate(self, skill):
|
|
27
|
+
skill = await super().validate(skill)
|
|
28
|
+
if not skill.metadata.tags:
|
|
29
|
+
raise SkillValidationError("every Skill needs at least one tag")
|
|
30
|
+
return skill
|
|
31
|
+
```
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def validate(self, skill: Skill) -> Skill:
|
|
36
|
+
"""Validate and return a normalized Skill.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
SkillValidationError: If the Skill violates a rule.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DefaultSkillValidator(SkillValidator):
|
|
44
|
+
"""Enforce lifecycle and governance invariants beyond the model schema."""
|
|
45
|
+
|
|
46
|
+
async def validate(self, skill: Skill) -> Skill:
|
|
47
|
+
"""Reject an ``ACTIVE`` Skill that carries a rejection event."""
|
|
48
|
+
if (
|
|
49
|
+
skill.lifecycle.state is SkillLifecycleState.ACTIVE
|
|
50
|
+
and skill.governance.rejection_event_id
|
|
51
|
+
):
|
|
52
|
+
raise SkillValidationError("a rejected Skill revision cannot be active")
|
|
53
|
+
return skill
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_mapping(content: str) -> dict[str, Any]:
|
|
57
|
+
try:
|
|
58
|
+
value: Any = yaml.safe_load(content)
|
|
59
|
+
except yaml.YAMLError as error:
|
|
60
|
+
raise SkillValidationError("malformed Skill YAML") from error
|
|
61
|
+
if not isinstance(value, dict):
|
|
62
|
+
raise SkillValidationError("a Skill YAML document must be a mapping")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def parse_skill_yaml(content: str, *, max_bytes: int = 1_000_000) -> Skill:
|
|
67
|
+
"""Parse one Skill YAML document without executing any of it.
|
|
68
|
+
|
|
69
|
+
Parsing runs in a worker thread so large documents never block the event loop.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
content: The YAML text.
|
|
73
|
+
max_bytes: Largest accepted UTF-8 size.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The schema-valid Skill; run a `SkillValidator` afterwards for policy checks.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
SkillValidationError: If the document is too large, malformed, lacks an
|
|
80
|
+
explicit ``skill_id``, or does not match the Skill schema.
|
|
81
|
+
"""
|
|
82
|
+
if len(content.encode()) > max_bytes:
|
|
83
|
+
raise SkillValidationError(f"Skill YAML exceeds the {max_bytes}-byte document limit")
|
|
84
|
+
value = await asyncio.to_thread(_load_mapping, content)
|
|
85
|
+
if "skill_id" not in value:
|
|
86
|
+
raise SkillValidationError("skill_id is required in persisted Skill YAML")
|
|
87
|
+
try:
|
|
88
|
+
return Skill.model_validate(value)
|
|
89
|
+
except ValidationError as error:
|
|
90
|
+
raise SkillValidationError("Skill YAML does not match Skill schema version 1.0") from error
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def dump_skill_yaml(skill: Skill) -> str:
|
|
94
|
+
"""Serialize a Skill to the canonical YAML document format."""
|
|
95
|
+
return yaml.safe_dump(skill.model_dump(mode="json"), allow_unicode=True, sort_keys=True)
|