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.
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"]