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/api.py
ADDED
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
"""Async application facade for Skill lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import weakref
|
|
9
|
+
from collections import OrderedDict, deque
|
|
10
|
+
from collections.abc import AsyncGenerator, Hashable, Iterable, Mapping, Sequence
|
|
11
|
+
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from types import MappingProxyType, TracebackType
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from agxai import DecisionType, EvidenceType, XAIRuntime
|
|
18
|
+
from feedback_manager import (
|
|
19
|
+
FeedbackCategory,
|
|
20
|
+
FeedbackEvent,
|
|
21
|
+
FeedbackManager,
|
|
22
|
+
FeedbackManagerError,
|
|
23
|
+
FeedbackQuery,
|
|
24
|
+
FeedbackSource,
|
|
25
|
+
FeedbackStatus,
|
|
26
|
+
FeedbackTarget,
|
|
27
|
+
FeedbackTargetType,
|
|
28
|
+
)
|
|
29
|
+
from mcp_capability_router import Capability, MCPCapabilityRouterError, MCPRuntime
|
|
30
|
+
from pydantic import ValidationError
|
|
31
|
+
from refresh_engine import (
|
|
32
|
+
AsyncScheduler,
|
|
33
|
+
EventPublisher,
|
|
34
|
+
RefreshEngine,
|
|
35
|
+
RefreshError,
|
|
36
|
+
RefreshMode,
|
|
37
|
+
RefreshResult,
|
|
38
|
+
RefreshStatus,
|
|
39
|
+
ResourceSelector,
|
|
40
|
+
TriggerSource,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
from skill_manager._xai import record_decision
|
|
44
|
+
from skill_manager.config import SkillManagerConfig
|
|
45
|
+
from skill_manager.discovery import DiscoveryProvider, PipelineDiscoveryProvider
|
|
46
|
+
from skill_manager.errors import (
|
|
47
|
+
CapabilityResolutionError,
|
|
48
|
+
SkillApprovalError,
|
|
49
|
+
SkillBundleError,
|
|
50
|
+
SkillConflictError,
|
|
51
|
+
SkillCreationDisabledError,
|
|
52
|
+
SkillGovernanceError,
|
|
53
|
+
SkillLifecycleError,
|
|
54
|
+
SkillManagerError,
|
|
55
|
+
SkillNotFoundError,
|
|
56
|
+
SkillRejectionError,
|
|
57
|
+
SkillSourceError,
|
|
58
|
+
)
|
|
59
|
+
from skill_manager.generation import SkillFeedback, SkillGenerationRequest, SkillGenerator
|
|
60
|
+
from skill_manager.models import (
|
|
61
|
+
Skill,
|
|
62
|
+
SkillBundle,
|
|
63
|
+
SkillGovernance,
|
|
64
|
+
SkillLifecycle,
|
|
65
|
+
SkillLifecycleState,
|
|
66
|
+
SkillOrigin,
|
|
67
|
+
)
|
|
68
|
+
from skill_manager.observability import (
|
|
69
|
+
NoOpSkillObservabilitySink,
|
|
70
|
+
SkillObservabilityEvent,
|
|
71
|
+
SkillObservabilitySink,
|
|
72
|
+
SkillObservabilityValue,
|
|
73
|
+
)
|
|
74
|
+
from skill_manager.registry import InMemorySkillRegistry, SkillRegistry
|
|
75
|
+
from skill_manager.resolution import resolve_dependencies
|
|
76
|
+
from skill_manager.validation import DefaultSkillValidator, SkillValidator, parse_skill_yaml
|
|
77
|
+
|
|
78
|
+
if TYPE_CHECKING:
|
|
79
|
+
from skill_manager.sources import SkillSource
|
|
80
|
+
|
|
81
|
+
logger = logging.getLogger(__name__)
|
|
82
|
+
_SECRET_KEY = re.compile(r"(?:secret|password|token|credential|api[_-]?key|authorization)", re.I)
|
|
83
|
+
_SKILL_TARGET = FeedbackTargetType("skill")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _reject_secret_keys(metadata: Mapping[str, Any]) -> None:
|
|
87
|
+
for key, value in metadata.items():
|
|
88
|
+
if _SECRET_KEY.search(key):
|
|
89
|
+
raise SkillGovernanceError("feedback metadata may not contain credential-like keys")
|
|
90
|
+
if isinstance(value, Mapping):
|
|
91
|
+
_reject_secret_keys(value)
|
|
92
|
+
elif isinstance(value, (tuple, list)):
|
|
93
|
+
for item in value:
|
|
94
|
+
if isinstance(item, Mapping):
|
|
95
|
+
_reject_secret_keys(item)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _KeyedLocks:
|
|
99
|
+
"""One asyncio lock per key, dropped as soon as nobody holds or awaits it."""
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
self._locks: weakref.WeakValueDictionary[Hashable, asyncio.Lock] = (
|
|
103
|
+
weakref.WeakValueDictionary()
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def __call__(self, key: Hashable) -> asyncio.Lock:
|
|
107
|
+
lock = self._locks.get(key)
|
|
108
|
+
if lock is None:
|
|
109
|
+
lock = asyncio.Lock()
|
|
110
|
+
self._locks[key] = lock
|
|
111
|
+
return lock
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SkillManager:
|
|
115
|
+
"""Manage the Skill lifecycle and hand resolved Skills to any agent framework.
|
|
116
|
+
|
|
117
|
+
The manager validates, stores, discovers, governs and composes Skills. It
|
|
118
|
+
never executes a Skill or runs an agent, so it works with LangGraph, CrewAI,
|
|
119
|
+
Agno or plain Python alike. Every collaborator is an injected, fully built
|
|
120
|
+
instance, so each downstream package keeps all of its own options.
|
|
121
|
+
|
|
122
|
+
Use it as an async context manager, or call `aclose` when done:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
async with SkillManager(sources=[FilesystemSkillSource("skills")]) as manager:
|
|
126
|
+
skill = await manager.load("summarize-ticket")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
sources: Skill sources; the first is the default write target.
|
|
131
|
+
registry: Catalog; defaults to `InMemorySkillRegistry`.
|
|
132
|
+
discovery: Discovery provider; defaults to lexical `PipelineDiscoveryProvider`.
|
|
133
|
+
validator: Policy validator; defaults to `DefaultSkillValidator`.
|
|
134
|
+
config: Policy switches and limits.
|
|
135
|
+
feedback_manager: Records approvals and rejections; defaults to an
|
|
136
|
+
in-memory `FeedbackManager` that the manager owns and closes.
|
|
137
|
+
mcp_runtime: Resolves required MCP capabilities in `build_bundle`.
|
|
138
|
+
refresh_engine: Refreshed before sources are reloaded in `refresh`.
|
|
139
|
+
xai_runtime: Receives evidence and decisions for generation,
|
|
140
|
+
approval, rejection and bundle composition.
|
|
141
|
+
observability_sink: Receives sanitized lifecycle events.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
ValueError: If two sources share a ``source_id``.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
*,
|
|
150
|
+
sources: Iterable[SkillSource] = (),
|
|
151
|
+
registry: SkillRegistry | None = None,
|
|
152
|
+
discovery: DiscoveryProvider | None = None,
|
|
153
|
+
validator: SkillValidator | None = None,
|
|
154
|
+
config: SkillManagerConfig | None = None,
|
|
155
|
+
feedback_manager: FeedbackManager | None = None,
|
|
156
|
+
mcp_runtime: MCPRuntime | None = None,
|
|
157
|
+
refresh_engine: RefreshEngine | None = None,
|
|
158
|
+
xai_runtime: XAIRuntime | None = None,
|
|
159
|
+
observability_sink: SkillObservabilitySink | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
self.config = config if config is not None else SkillManagerConfig()
|
|
162
|
+
self._sources = tuple(sources)
|
|
163
|
+
self._source_by_id = {source.source_id: source for source in self._sources}
|
|
164
|
+
if len(self._source_by_id) != len(self._sources):
|
|
165
|
+
raise ValueError("Skill source IDs must be unique")
|
|
166
|
+
self._registry = registry if registry is not None else InMemorySkillRegistry()
|
|
167
|
+
self._discovery = discovery if discovery is not None else PipelineDiscoveryProvider()
|
|
168
|
+
self._validator = validator if validator is not None else DefaultSkillValidator()
|
|
169
|
+
self._owns_feedback = feedback_manager is None
|
|
170
|
+
self._feedback = feedback_manager if feedback_manager is not None else FeedbackManager()
|
|
171
|
+
self._mcp_runtime = mcp_runtime
|
|
172
|
+
self._refresh_engine = refresh_engine
|
|
173
|
+
self._xai = xai_runtime
|
|
174
|
+
self._observability = (
|
|
175
|
+
observability_sink if observability_sink is not None else NoOpSkillObservabilitySink()
|
|
176
|
+
)
|
|
177
|
+
self._observability_errors: deque[Exception] = deque(maxlen=self.config.error_history_size)
|
|
178
|
+
self._source_errors: dict[str, str] = {}
|
|
179
|
+
self._source_for_skill: dict[str, str] = {}
|
|
180
|
+
self._documents_by_source: dict[str, dict[str, str]] = {}
|
|
181
|
+
self._source_locks = {source.source_id: asyncio.Lock() for source in self._sources}
|
|
182
|
+
self._catalog_locks = _KeyedLocks()
|
|
183
|
+
self._lifecycle_locks = _KeyedLocks()
|
|
184
|
+
self._load_lock = asyncio.Lock()
|
|
185
|
+
self._reindex_lock = asyncio.Lock()
|
|
186
|
+
self._scheduler_lock = asyncio.Lock()
|
|
187
|
+
self._capabilities: OrderedDict[str, Capability] = OrderedDict()
|
|
188
|
+
self._capability_epoch = 0
|
|
189
|
+
self._catalog_version = 0
|
|
190
|
+
self._index_dirty = True
|
|
191
|
+
self._loaded = False
|
|
192
|
+
self._scheduler: AsyncScheduler | None = None
|
|
193
|
+
|
|
194
|
+
async def __aenter__(self) -> Self:
|
|
195
|
+
"""Return the manager."""
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
async def __aexit__(
|
|
199
|
+
self,
|
|
200
|
+
exc_type: type[BaseException] | None,
|
|
201
|
+
exc: BaseException | None,
|
|
202
|
+
traceback: TracebackType | None,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Close the manager."""
|
|
205
|
+
await self.aclose()
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def source_errors(self) -> Mapping[str, str]:
|
|
209
|
+
"""Return the latest per-source and per-document load errors."""
|
|
210
|
+
return MappingProxyType(dict(self._source_errors))
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def observability_errors(self) -> tuple[Exception, ...]:
|
|
214
|
+
"""Return recent sink and XAI failures that did not interrupt Skill operations."""
|
|
215
|
+
return tuple(self._observability_errors)
|
|
216
|
+
|
|
217
|
+
async def load(self, name_or_skill_id: str, *, version: str | None = None) -> Skill:
|
|
218
|
+
"""Return a Skill by immutable ID, or the active Skill with a name and optional version.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
SkillNotFoundError: If no Skill matches.
|
|
222
|
+
SkillSourceError: If nothing matches and a source failed to load,
|
|
223
|
+
so the Skill may exist in the unreadable source.
|
|
224
|
+
"""
|
|
225
|
+
await self._ensure_loaded()
|
|
226
|
+
try:
|
|
227
|
+
if name_or_skill_id.startswith("sk_"):
|
|
228
|
+
return await self._registry.get(name_or_skill_id)
|
|
229
|
+
return await self._registry.by_name(name_or_skill_id, version)
|
|
230
|
+
except SkillNotFoundError:
|
|
231
|
+
if self._source_errors:
|
|
232
|
+
raise SkillSourceError(
|
|
233
|
+
"Skill sources could not all be read; see manager.source_errors"
|
|
234
|
+
) from None
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
async def discover(
|
|
238
|
+
self,
|
|
239
|
+
query: str,
|
|
240
|
+
*,
|
|
241
|
+
top_k: int = 5,
|
|
242
|
+
filters: Mapping[str, Any] | None = None,
|
|
243
|
+
) -> list[Skill]:
|
|
244
|
+
"""Return at most ``top_k`` active Skills ranked for ``query``.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
query: Natural-language task description.
|
|
248
|
+
top_k: Maximum number of Skills.
|
|
249
|
+
filters: Exact ``name``, ``version``, ``tag`` or ``capability`` filters.
|
|
250
|
+
"""
|
|
251
|
+
await self._ensure_loaded()
|
|
252
|
+
await self._reindex_if_needed()
|
|
253
|
+
matches = await self._discovery.discover(query, top_k=top_k, filters=filters)
|
|
254
|
+
await self._emit(
|
|
255
|
+
"skill.discovery.completed",
|
|
256
|
+
query_length=len(query),
|
|
257
|
+
requested_top_k=top_k,
|
|
258
|
+
result_count=len(matches),
|
|
259
|
+
filtered=bool(filters),
|
|
260
|
+
)
|
|
261
|
+
return matches
|
|
262
|
+
|
|
263
|
+
async def register_skill(self, skill: Skill, *, source_id: str | None = None) -> Skill:
|
|
264
|
+
"""Validate, govern, register and persist one Skill revision.
|
|
265
|
+
|
|
266
|
+
Agent Skills are held in ``PENDING_APPROVAL`` when human approval is
|
|
267
|
+
required; pending revisions stay in the registry until they are approved.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
skill: The revision to register.
|
|
271
|
+
source_id: Source to persist to; defaults to the first source.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
The registered Skill, with its governed lifecycle state.
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
SkillCreationDisabledError: If agent Skills are disabled.
|
|
278
|
+
SkillGovernanceError: If an agent Skill bypasses policy.
|
|
279
|
+
SkillValidationError: If the validator rejects the Skill.
|
|
280
|
+
SkillConflictError: If the revision conflicts with the catalog.
|
|
281
|
+
SkillSourceError: If persistence fails.
|
|
282
|
+
"""
|
|
283
|
+
await self._ensure_loaded()
|
|
284
|
+
if skill.provenance.origin is SkillOrigin.AGENT:
|
|
285
|
+
self._require_agent_creation()
|
|
286
|
+
if self.config.require_human_approval:
|
|
287
|
+
skill = skill.model_copy(
|
|
288
|
+
update={"lifecycle": SkillLifecycle(state=SkillLifecycleState.PENDING_APPROVAL)}
|
|
289
|
+
)
|
|
290
|
+
elif skill.lifecycle.state is not SkillLifecycleState.ACTIVE:
|
|
291
|
+
raise SkillGovernanceError(
|
|
292
|
+
"agent Skill must be ACTIVE when human approval is disabled"
|
|
293
|
+
)
|
|
294
|
+
skill = await self._validator.validate(skill)
|
|
295
|
+
source = self._select_source(source_id)
|
|
296
|
+
await self._write(
|
|
297
|
+
skill, source, persist=skill.lifecycle.state is not SkillLifecycleState.PENDING_APPROVAL
|
|
298
|
+
)
|
|
299
|
+
logger.info(
|
|
300
|
+
"skill_registered",
|
|
301
|
+
extra={"skill_id": skill.skill_id, "state": skill.lifecycle.state.value},
|
|
302
|
+
)
|
|
303
|
+
await self._emit(
|
|
304
|
+
"skill.registered",
|
|
305
|
+
skill_id=skill.skill_id,
|
|
306
|
+
skill_name=skill.metadata.name,
|
|
307
|
+
skill_version=skill.metadata.version,
|
|
308
|
+
lifecycle_state=skill.lifecycle.state.value,
|
|
309
|
+
origin=skill.provenance.origin.value,
|
|
310
|
+
persisted=source is not None,
|
|
311
|
+
)
|
|
312
|
+
return skill
|
|
313
|
+
|
|
314
|
+
async def generate_skill(
|
|
315
|
+
self,
|
|
316
|
+
generator: SkillGenerator,
|
|
317
|
+
input: Any,
|
|
318
|
+
*,
|
|
319
|
+
parent_skill_id: str | None = None,
|
|
320
|
+
source_id: str | None = None,
|
|
321
|
+
) -> Skill:
|
|
322
|
+
"""Generate, govern and register an agent Skill.
|
|
323
|
+
|
|
324
|
+
With ``parent_skill_id``, the generator also receives the parent revision
|
|
325
|
+
and its recorded rejections, and the result becomes the next revision
|
|
326
|
+
in the parent's lineage.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
generator: Produces the candidate Skill.
|
|
330
|
+
input: Task description passed to the generator.
|
|
331
|
+
parent_skill_id: Rejected revision to improve.
|
|
332
|
+
source_id: Source to persist to; defaults to the first source.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
SkillCreationDisabledError: If agent Skills are disabled.
|
|
336
|
+
SkillGenerationError: If the generator cannot produce a Skill.
|
|
337
|
+
"""
|
|
338
|
+
self._require_agent_creation()
|
|
339
|
+
parent = await self.load(parent_skill_id) if parent_skill_id is not None else None
|
|
340
|
+
feedback = await self._rejections(parent) if parent is not None else ()
|
|
341
|
+
candidate = await generator.generate(
|
|
342
|
+
SkillGenerationRequest(input=input, parent=parent, feedback=feedback)
|
|
343
|
+
)
|
|
344
|
+
lineage = (parent.skill_id,) if parent is not None else ()
|
|
345
|
+
candidate = candidate.model_copy(
|
|
346
|
+
update={
|
|
347
|
+
"provenance": candidate.provenance.model_copy(
|
|
348
|
+
update={
|
|
349
|
+
"origin": SkillOrigin.AGENT,
|
|
350
|
+
"source": generator.name,
|
|
351
|
+
"created_at": datetime.now(UTC),
|
|
352
|
+
"parent_skill_ids": lineage,
|
|
353
|
+
}
|
|
354
|
+
),
|
|
355
|
+
"revision": parent.revision + 1 if parent is not None else 1,
|
|
356
|
+
"lifecycle": SkillLifecycle(),
|
|
357
|
+
"governance": SkillGovernance(),
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
skill = await self.register_skill(candidate, source_id=source_id)
|
|
361
|
+
await self._explain(
|
|
362
|
+
"generate_skill",
|
|
363
|
+
decision_type=DecisionType.CUSTOM,
|
|
364
|
+
evidence_type=EvidenceType.MODEL_OUTPUT,
|
|
365
|
+
summary=(
|
|
366
|
+
f"{generator.name} generated Skill {skill.metadata.name} {skill.metadata.version}"
|
|
367
|
+
),
|
|
368
|
+
skill=skill,
|
|
369
|
+
parent_skill_id=parent_skill_id or "",
|
|
370
|
+
feedback_count=len(feedback),
|
|
371
|
+
)
|
|
372
|
+
return skill
|
|
373
|
+
|
|
374
|
+
async def approve_skill(
|
|
375
|
+
self,
|
|
376
|
+
skill_id: str,
|
|
377
|
+
*,
|
|
378
|
+
reviewer: str | None = None,
|
|
379
|
+
reason: str | None = None,
|
|
380
|
+
metadata: Mapping[str, Any] | None = None,
|
|
381
|
+
) -> Skill:
|
|
382
|
+
"""Record a human approval and activate a ``PENDING_APPROVAL`` Skill.
|
|
383
|
+
|
|
384
|
+
The approval is submitted to feedback-manager, the active revision is
|
|
385
|
+
persisted, and only then is the feedback event resolved. If resolving
|
|
386
|
+
fails, the Skill is returned to ``PENDING_APPROVAL``.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
SkillLifecycleError: If the Skill is not pending.
|
|
390
|
+
SkillGovernanceError: If ``metadata`` contains credential-like keys.
|
|
391
|
+
SkillApprovalError: If feedback or persistence fails.
|
|
392
|
+
"""
|
|
393
|
+
return await self._decide(
|
|
394
|
+
skill_id, approve=True, reviewer=reviewer, reason=reason, metadata=metadata
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
async def reject_skill(
|
|
398
|
+
self,
|
|
399
|
+
skill_id: str,
|
|
400
|
+
*,
|
|
401
|
+
feedback: str,
|
|
402
|
+
reviewer: str | None = None,
|
|
403
|
+
metadata: Mapping[str, Any] | None = None,
|
|
404
|
+
) -> Skill:
|
|
405
|
+
"""Record a human rejection and retain the ``REJECTED`` revision for audit.
|
|
406
|
+
|
|
407
|
+
The ``feedback`` text is later passed to a `SkillGenerator` through
|
|
408
|
+
`generate_skill` with ``parent_skill_id``.
|
|
409
|
+
|
|
410
|
+
Raises:
|
|
411
|
+
SkillLifecycleError: If the Skill is not pending.
|
|
412
|
+
SkillRejectionError: If ``feedback`` is empty, or feedback or persistence fails.
|
|
413
|
+
"""
|
|
414
|
+
if not feedback.strip():
|
|
415
|
+
raise SkillRejectionError("rejection feedback must not be empty")
|
|
416
|
+
return await self._decide(
|
|
417
|
+
skill_id, approve=False, reviewer=reviewer, reason=feedback, metadata=metadata
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
async def list_skills(
|
|
421
|
+
self,
|
|
422
|
+
*,
|
|
423
|
+
states: Iterable[SkillLifecycleState] | None = None,
|
|
424
|
+
origins: Iterable[SkillOrigin] | None = None,
|
|
425
|
+
) -> tuple[Skill, ...]:
|
|
426
|
+
"""Return registered revisions, optionally filtered by lifecycle state and origin."""
|
|
427
|
+
await self._ensure_loaded()
|
|
428
|
+
skills = await self._registry.all(states=states)
|
|
429
|
+
if origins is None:
|
|
430
|
+
return skills
|
|
431
|
+
wanted = frozenset(origins)
|
|
432
|
+
return tuple(skill for skill in skills if skill.provenance.origin in wanted)
|
|
433
|
+
|
|
434
|
+
async def list_pending_skills(self) -> tuple[Skill, ...]:
|
|
435
|
+
"""Return the review queue: every ``PENDING_APPROVAL`` revision."""
|
|
436
|
+
return await self.list_skills(states={SkillLifecycleState.PENDING_APPROVAL})
|
|
437
|
+
|
|
438
|
+
async def remove_skill(self, skill_id: str) -> None:
|
|
439
|
+
"""Delete a non-rejected revision from its source and the catalog.
|
|
440
|
+
|
|
441
|
+
Raises:
|
|
442
|
+
SkillLifecycleError: If the Skill is ``REJECTED``; rejections are kept for audit.
|
|
443
|
+
"""
|
|
444
|
+
await self._ensure_loaded()
|
|
445
|
+
skill = await self._registry.get(skill_id)
|
|
446
|
+
if skill.lifecycle.state is SkillLifecycleState.REJECTED:
|
|
447
|
+
raise SkillLifecycleError("rejected revisions are retained for auditability")
|
|
448
|
+
source = self._source_by_id.get(self._source_for_skill.get(skill_id, ""))
|
|
449
|
+
async with self._locked(skill, source):
|
|
450
|
+
if source is not None:
|
|
451
|
+
await source.delete(skill_id)
|
|
452
|
+
await self._registry.remove(skill_id)
|
|
453
|
+
self._source_for_skill.pop(skill_id, None)
|
|
454
|
+
self._mark_index_dirty()
|
|
455
|
+
await self._emit("skill.removed", skill_id=skill_id, skill_name=skill.metadata.name)
|
|
456
|
+
|
|
457
|
+
async def build_bundle(self, skills: Iterable[Skill | str]) -> SkillBundle:
|
|
458
|
+
"""Resolve dependencies and MCP capabilities into a `SkillBundle`.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
skills: Root Skills, or their IDs or names.
|
|
462
|
+
|
|
463
|
+
Raises:
|
|
464
|
+
SkillBundleError: If no root is given, a root is not ``ACTIVE``, or
|
|
465
|
+
two Skills define the same prompt key differently.
|
|
466
|
+
SkillDependencyError: If a dependency cannot be resolved.
|
|
467
|
+
CapabilityResolutionError: If a required capability is unavailable.
|
|
468
|
+
"""
|
|
469
|
+
roots: list[Skill] = []
|
|
470
|
+
for item in skills:
|
|
471
|
+
root = await self.load(item if isinstance(item, str) else item.skill_id)
|
|
472
|
+
if root.lifecycle.state is not SkillLifecycleState.ACTIVE:
|
|
473
|
+
raise SkillBundleError(
|
|
474
|
+
f"Skill {root.skill_id!r} is {root.lifecycle.state.value}, not ACTIVE"
|
|
475
|
+
)
|
|
476
|
+
roots.append(root)
|
|
477
|
+
if not roots:
|
|
478
|
+
raise SkillBundleError("at least one active Skill is required")
|
|
479
|
+
catalog = await self._registry.all(states={SkillLifecycleState.ACTIVE})
|
|
480
|
+
resolved = resolve_dependencies(roots, catalog)
|
|
481
|
+
prompts: dict[str, str] = {}
|
|
482
|
+
for skill in resolved:
|
|
483
|
+
for key, value in skill.prompts.items():
|
|
484
|
+
if prompts.setdefault(key, value) != value:
|
|
485
|
+
raise SkillBundleError(f"Skills define prompt {key!r} differently")
|
|
486
|
+
capabilities = await self._resolve_capabilities(resolved)
|
|
487
|
+
try:
|
|
488
|
+
bundle = SkillBundle(
|
|
489
|
+
skills=resolved,
|
|
490
|
+
instructions=tuple(line for skill in resolved for line in skill.instructions),
|
|
491
|
+
dependency_order=tuple(skill.skill_id for skill in resolved),
|
|
492
|
+
dependencies=tuple(dep for skill in resolved for dep in skill.dependencies),
|
|
493
|
+
capabilities=capabilities,
|
|
494
|
+
resources=tuple(res for skill in resolved for res in skill.resources),
|
|
495
|
+
prompts=prompts,
|
|
496
|
+
provenance=tuple(skill.provenance for skill in resolved),
|
|
497
|
+
metadata={"root_skill_ids": ",".join(skill.skill_id for skill in roots)},
|
|
498
|
+
)
|
|
499
|
+
except ValidationError as error:
|
|
500
|
+
raise SkillBundleError("resolved Skills do not form a valid SkillBundle") from error
|
|
501
|
+
await self._emit(
|
|
502
|
+
"skill.bundle.built",
|
|
503
|
+
root_count=len(roots),
|
|
504
|
+
skill_count=len(bundle.skills),
|
|
505
|
+
capability_count=len(bundle.capabilities),
|
|
506
|
+
)
|
|
507
|
+
await self._explain(
|
|
508
|
+
"build_bundle",
|
|
509
|
+
decision_type=DecisionType.CUSTOM,
|
|
510
|
+
evidence_type=EvidenceType.RULE,
|
|
511
|
+
summary=f"resolved {len(resolved)} Skills from {len(roots)} roots",
|
|
512
|
+
dependency_order=list(bundle.dependency_order),
|
|
513
|
+
capabilities=sorted(capabilities),
|
|
514
|
+
)
|
|
515
|
+
return bundle
|
|
516
|
+
|
|
517
|
+
async def refresh(
|
|
518
|
+
self,
|
|
519
|
+
*,
|
|
520
|
+
mode: RefreshMode | None = None,
|
|
521
|
+
resource_ids: str | Iterable[str] = (),
|
|
522
|
+
selector: ResourceSelector | None = None,
|
|
523
|
+
trigger: TriggerSource = TriggerSource.MANUAL,
|
|
524
|
+
metadata: Mapping[str, Any] | None = None,
|
|
525
|
+
correlation_id: str | None = None,
|
|
526
|
+
) -> RefreshResult | None:
|
|
527
|
+
"""Run the refresh engine, if any, then reload every Skill source.
|
|
528
|
+
|
|
529
|
+
All arguments are passed to `RefreshEngine.refresh`. Healthy sources are
|
|
530
|
+
committed even when another source fails.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
The refresh-engine result, or None without a refresh engine.
|
|
534
|
+
|
|
535
|
+
Raises:
|
|
536
|
+
SkillSourceError: If the refresh engine fails or any source fails.
|
|
537
|
+
"""
|
|
538
|
+
result: RefreshResult | None = None
|
|
539
|
+
if self._refresh_engine is not None:
|
|
540
|
+
try:
|
|
541
|
+
result = await self._refresh_engine.refresh(
|
|
542
|
+
mode=mode,
|
|
543
|
+
resource_ids=resource_ids,
|
|
544
|
+
selector=selector,
|
|
545
|
+
trigger=trigger,
|
|
546
|
+
metadata=metadata,
|
|
547
|
+
correlation_id=correlation_id,
|
|
548
|
+
)
|
|
549
|
+
except RefreshError as error:
|
|
550
|
+
raise SkillSourceError("refresh-engine refresh failed") from error
|
|
551
|
+
if result.status is RefreshStatus.FAILED:
|
|
552
|
+
raise SkillSourceError("refresh-engine reported a failed Skill refresh")
|
|
553
|
+
failed = await self._load_sources()
|
|
554
|
+
await self._reindex_if_needed()
|
|
555
|
+
self._capability_epoch += 1
|
|
556
|
+
self._capabilities.clear()
|
|
557
|
+
self._loaded = True
|
|
558
|
+
await self._emit(
|
|
559
|
+
"skill.refresh.completed",
|
|
560
|
+
source_count=len(self._sources),
|
|
561
|
+
external_engine=self._refresh_engine is not None,
|
|
562
|
+
source_error_count=len(failed),
|
|
563
|
+
)
|
|
564
|
+
if failed:
|
|
565
|
+
raise SkillSourceError(
|
|
566
|
+
"one or more Skill sources failed; healthy sources were retained: "
|
|
567
|
+
+ ", ".join(sorted(failed))
|
|
568
|
+
)
|
|
569
|
+
return result
|
|
570
|
+
|
|
571
|
+
async def schedule_refresh(
|
|
572
|
+
self,
|
|
573
|
+
interval: float,
|
|
574
|
+
*,
|
|
575
|
+
run_immediately: bool = False,
|
|
576
|
+
events: EventPublisher | None = None,
|
|
577
|
+
) -> AsyncScheduler:
|
|
578
|
+
"""Start a refresh-engine `AsyncScheduler` that calls `refresh` every ``interval`` seconds.
|
|
579
|
+
|
|
580
|
+
The manager owns the scheduler and stops it in `aclose`.
|
|
581
|
+
|
|
582
|
+
Raises:
|
|
583
|
+
SkillGovernanceError: If a scheduler is already running.
|
|
584
|
+
"""
|
|
585
|
+
async with self._scheduler_lock:
|
|
586
|
+
if self._scheduler is not None:
|
|
587
|
+
raise SkillGovernanceError("a scheduled refresh is already running")
|
|
588
|
+
|
|
589
|
+
async def scheduled_refresh() -> None:
|
|
590
|
+
await self.refresh(trigger=TriggerSource.SCHEDULED)
|
|
591
|
+
|
|
592
|
+
scheduler = AsyncScheduler(
|
|
593
|
+
scheduled_refresh, interval, events=events, run_immediately=run_immediately
|
|
594
|
+
)
|
|
595
|
+
await scheduler.start()
|
|
596
|
+
self._scheduler = scheduler
|
|
597
|
+
return scheduler
|
|
598
|
+
|
|
599
|
+
async def aclose(self) -> None:
|
|
600
|
+
"""Stop the owned scheduler and close the owned feedback manager.
|
|
601
|
+
|
|
602
|
+
Injected collaborators stay open; their owner closes them.
|
|
603
|
+
"""
|
|
604
|
+
async with self._scheduler_lock:
|
|
605
|
+
if self._scheduler is not None:
|
|
606
|
+
await self._scheduler.stop()
|
|
607
|
+
self._scheduler = None
|
|
608
|
+
if self._owns_feedback:
|
|
609
|
+
await self._feedback.aclose()
|
|
610
|
+
|
|
611
|
+
async def _decide(
|
|
612
|
+
self,
|
|
613
|
+
skill_id: str,
|
|
614
|
+
*,
|
|
615
|
+
approve: bool,
|
|
616
|
+
reviewer: str | None,
|
|
617
|
+
reason: str | None,
|
|
618
|
+
metadata: Mapping[str, Any] | None,
|
|
619
|
+
) -> Skill:
|
|
620
|
+
failure = SkillApprovalError if approve else SkillRejectionError
|
|
621
|
+
decision = "approved" if approve else "rejected"
|
|
622
|
+
details = dict(metadata or {})
|
|
623
|
+
_reject_secret_keys(details)
|
|
624
|
+
await self._ensure_loaded()
|
|
625
|
+
async with self._lifecycle_locks(skill_id):
|
|
626
|
+
skill = await self._registry.get(skill_id)
|
|
627
|
+
if skill.lifecycle.state is not SkillLifecycleState.PENDING_APPROVAL:
|
|
628
|
+
raise SkillLifecycleError(f"only a PENDING_APPROVAL Skill can be {decision}")
|
|
629
|
+
if approve and skill.provenance.origin is SkillOrigin.AGENT:
|
|
630
|
+
self._require_agent_creation()
|
|
631
|
+
try:
|
|
632
|
+
event = await self._feedback.submit(
|
|
633
|
+
source=FeedbackSource.HUMAN,
|
|
634
|
+
category=FeedbackCategory.APPROVAL if approve else FeedbackCategory.REJECTION,
|
|
635
|
+
target=FeedbackTarget(type=_SKILL_TARGET, id=skill_id),
|
|
636
|
+
payload={"decision": decision, "feedback": reason, "reviewer": reviewer},
|
|
637
|
+
metadata={"skill_revision": skill.revision, **details},
|
|
638
|
+
)
|
|
639
|
+
await self._feedback.acknowledge(event.feedback_id)
|
|
640
|
+
await self._feedback.mark_handled(event.feedback_id)
|
|
641
|
+
except FeedbackManagerError as error:
|
|
642
|
+
raise failure(f"{decision} feedback failed for Skill {skill_id!r}") from error
|
|
643
|
+
event_id = str(event.feedback_id)
|
|
644
|
+
updated = skill.model_copy(
|
|
645
|
+
update={
|
|
646
|
+
"lifecycle": SkillLifecycle(
|
|
647
|
+
state=SkillLifecycleState.ACTIVE
|
|
648
|
+
if approve
|
|
649
|
+
else SkillLifecycleState.REJECTED,
|
|
650
|
+
reason=reason,
|
|
651
|
+
),
|
|
652
|
+
"governance": SkillGovernance(
|
|
653
|
+
approval_event_id=event_id if approve else None,
|
|
654
|
+
rejection_event_id=None if approve else event_id,
|
|
655
|
+
reviewer=reviewer,
|
|
656
|
+
reason=reason,
|
|
657
|
+
),
|
|
658
|
+
}
|
|
659
|
+
)
|
|
660
|
+
try:
|
|
661
|
+
await self._replace_lifecycle(updated, previous=skill)
|
|
662
|
+
except SkillManagerError as error:
|
|
663
|
+
raise failure(f"Skill {skill_id!r} could not be {decision}") from error
|
|
664
|
+
try:
|
|
665
|
+
if approve:
|
|
666
|
+
await self._feedback.resolve(
|
|
667
|
+
event.feedback_id, resolution={"decision": decision}
|
|
668
|
+
)
|
|
669
|
+
else:
|
|
670
|
+
await self._feedback.reject(event.feedback_id, reason=reason)
|
|
671
|
+
except FeedbackManagerError as error:
|
|
672
|
+
await self._replace_lifecycle(skill, previous=updated)
|
|
673
|
+
raise failure(
|
|
674
|
+
f"{decision} feedback for Skill {skill_id!r} was not closed"
|
|
675
|
+
) from error
|
|
676
|
+
logger.info("skill_reviewed", extra={"skill_id": skill_id, "decision": decision})
|
|
677
|
+
await self._emit(
|
|
678
|
+
f"skill.{decision}",
|
|
679
|
+
skill_id=skill_id,
|
|
680
|
+
skill_name=updated.metadata.name,
|
|
681
|
+
skill_version=updated.metadata.version,
|
|
682
|
+
)
|
|
683
|
+
await self._explain(
|
|
684
|
+
f"{'approve' if approve else 'reject'}_skill",
|
|
685
|
+
decision_type=DecisionType.APPROVAL if approve else DecisionType.REJECTION,
|
|
686
|
+
evidence_type=EvidenceType.POLICY,
|
|
687
|
+
summary=f"human reviewer {decision} Skill {updated.metadata.name}",
|
|
688
|
+
skill=updated,
|
|
689
|
+
feedback_id=event_id,
|
|
690
|
+
reviewer=reviewer or "",
|
|
691
|
+
)
|
|
692
|
+
return updated
|
|
693
|
+
|
|
694
|
+
async def _rejections(self, parent: Skill) -> tuple[SkillFeedback, ...]:
|
|
695
|
+
limit = self.config.feedback_context_limit
|
|
696
|
+
if limit == 0:
|
|
697
|
+
return ()
|
|
698
|
+
events: Sequence[FeedbackEvent] = await self._feedback.query(
|
|
699
|
+
FeedbackQuery(
|
|
700
|
+
category=FeedbackCategory.REJECTION,
|
|
701
|
+
target_type=_SKILL_TARGET,
|
|
702
|
+
target_id=parent.skill_id,
|
|
703
|
+
status=FeedbackStatus.REJECTED,
|
|
704
|
+
limit=limit,
|
|
705
|
+
)
|
|
706
|
+
)
|
|
707
|
+
return tuple(
|
|
708
|
+
SkillFeedback(
|
|
709
|
+
feedback_id=str(event.feedback_id),
|
|
710
|
+
payload=dict(event.payload),
|
|
711
|
+
metadata=dict(event.metadata),
|
|
712
|
+
created_at=event.created_at,
|
|
713
|
+
)
|
|
714
|
+
for event in sorted(events, key=lambda item: item.created_at, reverse=True)
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
async def _resolve_capabilities(
|
|
718
|
+
self, skills: Sequence[Skill]
|
|
719
|
+
) -> dict[str, tuple[Capability, ...]]:
|
|
720
|
+
names = sorted({capability.name for skill in skills for capability in skill.capabilities})
|
|
721
|
+
if not names:
|
|
722
|
+
return {}
|
|
723
|
+
runtime = self._mcp_runtime
|
|
724
|
+
if runtime is None:
|
|
725
|
+
raise CapabilityResolutionError(
|
|
726
|
+
"Skills require MCP capabilities but no MCPRuntime was configured"
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
async def resolve(name: str) -> tuple[str, tuple[Capability, ...]]:
|
|
730
|
+
cached = self._capabilities.get(name)
|
|
731
|
+
if cached is not None:
|
|
732
|
+
self._capabilities.move_to_end(name)
|
|
733
|
+
return name, (cached,)
|
|
734
|
+
epoch = self._capability_epoch
|
|
735
|
+
try:
|
|
736
|
+
candidates = await runtime.retrieve(
|
|
737
|
+
name, limit=self.config.capability_candidate_limit
|
|
738
|
+
)
|
|
739
|
+
except MCPCapabilityRouterError as error:
|
|
740
|
+
raise CapabilityResolutionError(
|
|
741
|
+
f"MCP capability discovery failed for {name!r}"
|
|
742
|
+
) from error
|
|
743
|
+
matches = [item for item in candidates if name in {item.name, item.capability_id}]
|
|
744
|
+
if not matches:
|
|
745
|
+
raise CapabilityResolutionError(f"required capability {name!r} was not found")
|
|
746
|
+
selected = min(matches, key=lambda item: item.capability_id)
|
|
747
|
+
if epoch == self._capability_epoch and self.config.capability_cache_size:
|
|
748
|
+
self._capabilities[name] = selected
|
|
749
|
+
while len(self._capabilities) > self.config.capability_cache_size:
|
|
750
|
+
self._capabilities.popitem(last=False)
|
|
751
|
+
return name, (selected,)
|
|
752
|
+
|
|
753
|
+
return dict(await asyncio.gather(*(resolve(name) for name in names)))
|
|
754
|
+
|
|
755
|
+
async def _ensure_loaded(self) -> None:
|
|
756
|
+
if self._loaded:
|
|
757
|
+
return
|
|
758
|
+
async with self._load_lock:
|
|
759
|
+
if not self._loaded:
|
|
760
|
+
await self._load_sources()
|
|
761
|
+
await self._reindex_if_needed()
|
|
762
|
+
self._loaded = True
|
|
763
|
+
|
|
764
|
+
async def _load_sources(self) -> set[str]:
|
|
765
|
+
results = await asyncio.gather(*(self._load_source(source) for source in self._sources))
|
|
766
|
+
self._mark_index_dirty()
|
|
767
|
+
return {key for keys in results for key in keys}
|
|
768
|
+
|
|
769
|
+
async def _load_source(self, source: SkillSource) -> set[str]:
|
|
770
|
+
prefix = source.source_id + ":"
|
|
771
|
+
async with self._source_locks[source.source_id]:
|
|
772
|
+
for key in [key for key in self._source_errors if key.startswith(prefix)]:
|
|
773
|
+
del self._source_errors[key]
|
|
774
|
+
try:
|
|
775
|
+
documents = await source.load()
|
|
776
|
+
except Exception as error:
|
|
777
|
+
# One broken source must not hide the Skills of healthy sources.
|
|
778
|
+
self._source_errors[source.source_id] = f"{type(error).__name__}: {error}"
|
|
779
|
+
logger.warning("skill_source_load_failed", extra={"source_id": source.source_id})
|
|
780
|
+
return {source.source_id}
|
|
781
|
+
self._source_errors.pop(source.source_id, None)
|
|
782
|
+
previous = self._documents_by_source.get(source.source_id, {})
|
|
783
|
+
current: dict[str, str] = {}
|
|
784
|
+
failed: set[str] = set()
|
|
785
|
+
for locator, content in documents.items():
|
|
786
|
+
try:
|
|
787
|
+
skill = await self._ingest(content)
|
|
788
|
+
except (SkillManagerError, FeedbackManagerError) as error:
|
|
789
|
+
key = prefix + locator
|
|
790
|
+
self._source_errors[key] = f"{type(error).__name__}: {error}"
|
|
791
|
+
logger.warning("invalid_skill_ignored", extra={"source_id": source.source_id})
|
|
792
|
+
failed.add(key)
|
|
793
|
+
if locator in previous:
|
|
794
|
+
current[locator] = previous[locator]
|
|
795
|
+
continue
|
|
796
|
+
self._source_for_skill[skill.skill_id] = source.source_id
|
|
797
|
+
current[locator] = skill.skill_id
|
|
798
|
+
kept = set(current.values())
|
|
799
|
+
for skill_id in set(previous.values()) - kept:
|
|
800
|
+
await self._drop(skill_id, source.source_id)
|
|
801
|
+
self._documents_by_source[source.source_id] = current
|
|
802
|
+
return failed
|
|
803
|
+
|
|
804
|
+
async def _ingest(self, content: str) -> Skill:
|
|
805
|
+
skill = await parse_skill_yaml(content, max_bytes=self.config.max_document_bytes)
|
|
806
|
+
if skill.provenance.origin is SkillOrigin.AGENT:
|
|
807
|
+
self._require_agent_creation()
|
|
808
|
+
if (
|
|
809
|
+
self.config.require_human_approval
|
|
810
|
+
and skill.lifecycle.state is SkillLifecycleState.ACTIVE
|
|
811
|
+
and not await self._approval_is_recorded(skill)
|
|
812
|
+
):
|
|
813
|
+
skill = skill.model_copy(
|
|
814
|
+
update={"lifecycle": SkillLifecycle(state=SkillLifecycleState.PENDING_APPROVAL)}
|
|
815
|
+
)
|
|
816
|
+
skill = await self._validator.validate(skill)
|
|
817
|
+
async with self._catalog_locks((skill.metadata.name, skill.metadata.version)):
|
|
818
|
+
await self._registry.put(skill)
|
|
819
|
+
return skill
|
|
820
|
+
|
|
821
|
+
async def _drop(self, skill_id: str, source_id: str) -> None:
|
|
822
|
+
if self._source_for_skill.get(skill_id) != source_id:
|
|
823
|
+
return
|
|
824
|
+
try:
|
|
825
|
+
skill = await self._registry.get(skill_id)
|
|
826
|
+
async with self._catalog_locks((skill.metadata.name, skill.metadata.version)):
|
|
827
|
+
await self._registry.remove(skill_id)
|
|
828
|
+
except SkillNotFoundError:
|
|
829
|
+
pass
|
|
830
|
+
self._source_for_skill.pop(skill_id, None)
|
|
831
|
+
|
|
832
|
+
async def _approval_is_recorded(self, skill: Skill) -> bool:
|
|
833
|
+
try:
|
|
834
|
+
feedback_id = UUID(skill.governance.approval_event_id or "")
|
|
835
|
+
except ValueError:
|
|
836
|
+
return False
|
|
837
|
+
event = await self._feedback.get(feedback_id)
|
|
838
|
+
return (
|
|
839
|
+
event is not None
|
|
840
|
+
and event.target.type == _SKILL_TARGET
|
|
841
|
+
and event.target.id == skill.skill_id
|
|
842
|
+
and event.category == FeedbackCategory.APPROVAL
|
|
843
|
+
and event.status is FeedbackStatus.RESOLVED
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
async def _reindex_if_needed(self) -> None:
|
|
847
|
+
if not self._index_dirty:
|
|
848
|
+
return
|
|
849
|
+
async with self._reindex_lock:
|
|
850
|
+
while self._index_dirty:
|
|
851
|
+
version = self._catalog_version
|
|
852
|
+
await self._discovery.index(await self._registry.all())
|
|
853
|
+
if version == self._catalog_version:
|
|
854
|
+
self._index_dirty = False
|
|
855
|
+
|
|
856
|
+
def _select_source(self, source_id: str | None) -> SkillSource | None:
|
|
857
|
+
if source_id is None:
|
|
858
|
+
return self._sources[0] if self._sources else None
|
|
859
|
+
try:
|
|
860
|
+
return self._source_by_id[source_id]
|
|
861
|
+
except KeyError:
|
|
862
|
+
raise SkillSourceError(f"unknown Skill source {source_id!r}") from None
|
|
863
|
+
|
|
864
|
+
def _locked(
|
|
865
|
+
self, skill: Skill, source: SkillSource | None
|
|
866
|
+
) -> AbstractAsyncContextManager[None]:
|
|
867
|
+
@asynccontextmanager
|
|
868
|
+
async def locked() -> AsyncGenerator[None]:
|
|
869
|
+
async with AsyncExitStack() as stack:
|
|
870
|
+
if source is not None:
|
|
871
|
+
await stack.enter_async_context(self._source_locks[source.source_id])
|
|
872
|
+
await stack.enter_async_context(
|
|
873
|
+
self._catalog_locks((skill.metadata.name, skill.metadata.version))
|
|
874
|
+
)
|
|
875
|
+
yield
|
|
876
|
+
|
|
877
|
+
return locked()
|
|
878
|
+
|
|
879
|
+
async def _write(self, skill: Skill, source: SkillSource | None, *, persist: bool) -> None:
|
|
880
|
+
async with self._locked(skill, source):
|
|
881
|
+
await self._registry.check_put(skill)
|
|
882
|
+
if source is not None and persist:
|
|
883
|
+
await source.save(skill)
|
|
884
|
+
try:
|
|
885
|
+
await self._registry.put(skill)
|
|
886
|
+
except SkillManagerError:
|
|
887
|
+
if source is not None and persist:
|
|
888
|
+
await source.delete(skill.skill_id)
|
|
889
|
+
raise
|
|
890
|
+
if source is not None:
|
|
891
|
+
self._source_for_skill[skill.skill_id] = source.source_id
|
|
892
|
+
self._mark_index_dirty()
|
|
893
|
+
|
|
894
|
+
async def _replace_lifecycle(self, skill: Skill, *, previous: Skill) -> None:
|
|
895
|
+
source = self._source_by_id.get(self._source_for_skill.get(skill.skill_id, ""))
|
|
896
|
+
async with self._locked(skill, source):
|
|
897
|
+
current = await self._registry.get(skill.skill_id)
|
|
898
|
+
if current != previous:
|
|
899
|
+
raise SkillConflictError(
|
|
900
|
+
f"Skill {skill.skill_id!r} changed during a lifecycle transition"
|
|
901
|
+
)
|
|
902
|
+
if source is not None:
|
|
903
|
+
await source.save(skill)
|
|
904
|
+
try:
|
|
905
|
+
await self._registry.replace_lifecycle(skill)
|
|
906
|
+
except SkillManagerError:
|
|
907
|
+
if source is not None:
|
|
908
|
+
await source.save(previous)
|
|
909
|
+
raise
|
|
910
|
+
self._mark_index_dirty()
|
|
911
|
+
|
|
912
|
+
def _mark_index_dirty(self) -> None:
|
|
913
|
+
self._catalog_version += 1
|
|
914
|
+
self._index_dirty = True
|
|
915
|
+
|
|
916
|
+
def _require_agent_creation(self) -> None:
|
|
917
|
+
if not self.config.allow_agent_skill_creation:
|
|
918
|
+
raise SkillCreationDisabledError("agent-generated Skill creation is disabled")
|
|
919
|
+
|
|
920
|
+
async def _emit(self, name: str, **attributes: SkillObservabilityValue) -> None:
|
|
921
|
+
try:
|
|
922
|
+
await self._observability.emit(
|
|
923
|
+
SkillObservabilityEvent(name=name, attributes=MappingProxyType(attributes))
|
|
924
|
+
)
|
|
925
|
+
except Exception as error:
|
|
926
|
+
# Telemetry must never break a Skill operation.
|
|
927
|
+
self._observability_errors.append(error)
|
|
928
|
+
logger.exception("skill_observability_failed", extra={"event_name": name})
|
|
929
|
+
|
|
930
|
+
async def _explain(
|
|
931
|
+
self,
|
|
932
|
+
action: str,
|
|
933
|
+
*,
|
|
934
|
+
decision_type: DecisionType,
|
|
935
|
+
evidence_type: EvidenceType,
|
|
936
|
+
summary: str,
|
|
937
|
+
skill: Skill | None = None,
|
|
938
|
+
**metadata: Any,
|
|
939
|
+
) -> None:
|
|
940
|
+
if self._xai is None:
|
|
941
|
+
return
|
|
942
|
+
if skill is not None:
|
|
943
|
+
metadata |= {
|
|
944
|
+
"skill_id": skill.skill_id,
|
|
945
|
+
"skill_name": skill.metadata.name,
|
|
946
|
+
"skill_version": skill.metadata.version,
|
|
947
|
+
"skill_revision": skill.revision,
|
|
948
|
+
"lifecycle_state": skill.lifecycle.state.value,
|
|
949
|
+
}
|
|
950
|
+
try:
|
|
951
|
+
await record_decision(
|
|
952
|
+
self._xai,
|
|
953
|
+
action,
|
|
954
|
+
decision_type=decision_type,
|
|
955
|
+
evidence_type=evidence_type,
|
|
956
|
+
summary=summary,
|
|
957
|
+
metadata=metadata,
|
|
958
|
+
)
|
|
959
|
+
except Exception as error:
|
|
960
|
+
# Explanations must never break a Skill operation.
|
|
961
|
+
self._observability_errors.append(error)
|
|
962
|
+
logger.exception("skill_xai_failed", extra={"action": action})
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
__all__ = ["SkillManager"]
|