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/config.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Immutable Skill Manager policy and limit configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SkillManagerConfig(BaseModel):
|
|
9
|
+
"""Policy switches and resource limits for one `SkillManager`.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
allow_agent_skill_creation: Accept agent-generated Skills at all.
|
|
13
|
+
require_human_approval: Hold agent-generated Skills in
|
|
14
|
+
``PENDING_APPROVAL`` until a reviewer approves them.
|
|
15
|
+
max_document_bytes: Largest Skill YAML document a source may supply.
|
|
16
|
+
capability_candidate_limit: Candidates requested from
|
|
17
|
+
`MCPRuntime.retrieve` per required capability name.
|
|
18
|
+
capability_cache_size: Resolved capabilities kept between bundle builds.
|
|
19
|
+
feedback_context_limit: Rejection events passed to a `SkillGenerator`
|
|
20
|
+
when it revises a parent Skill.
|
|
21
|
+
error_history_size: Observability and XAI failures retained on
|
|
22
|
+
`SkillManager.observability_errors`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
26
|
+
|
|
27
|
+
allow_agent_skill_creation: bool = True
|
|
28
|
+
require_human_approval: bool = False
|
|
29
|
+
max_document_bytes: int = Field(default=1_000_000, ge=1)
|
|
30
|
+
capability_candidate_limit: int = Field(default=50, ge=1)
|
|
31
|
+
capability_cache_size: int = Field(default=1024, ge=0)
|
|
32
|
+
feedback_context_limit: int = Field(default=20, ge=0)
|
|
33
|
+
error_history_size: int = Field(default=100, ge=1)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Composable retrieval and reranking for bounded Skill discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from skill_manager.errors import SkillDiscoveryError
|
|
13
|
+
from skill_manager.models import Skill, SkillLifecycleState
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
from mcp_capability_router import Embedder
|
|
19
|
+
|
|
20
|
+
_TOKEN_PATTERN = re.compile(r"[a-z0-9][a-z0-9_.]*", re.IGNORECASE)
|
|
21
|
+
_FILTER_KEYS = frozenset({"name", "version", "tag", "capability"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class RetrievedSkill:
|
|
26
|
+
"""A retrieved Skill candidate and its backend-specific relevance score."""
|
|
27
|
+
|
|
28
|
+
skill: Skill
|
|
29
|
+
score: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SkillRetriever(ABC):
|
|
33
|
+
"""Extension point that indexes Skills and returns scored candidates.
|
|
34
|
+
|
|
35
|
+
`PipelineDiscoveryProvider` calls `index` whenever the catalog changes and
|
|
36
|
+
`retrieve` for each query. Return only ``ACTIVE`` Skills that satisfy
|
|
37
|
+
``filters``; use `matches_filters` to apply them.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def index(self, skills: Sequence[Skill]) -> None:
|
|
42
|
+
"""Replace the retriever's catalog snapshot."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def retrieve(
|
|
46
|
+
self,
|
|
47
|
+
query: str,
|
|
48
|
+
*,
|
|
49
|
+
limit: int,
|
|
50
|
+
filters: Mapping[str, Any] | None = None,
|
|
51
|
+
) -> list[RetrievedSkill]:
|
|
52
|
+
"""Return at most ``limit`` active, filtered candidates."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SkillReranker(ABC):
|
|
56
|
+
"""Extension point that orders retrieved candidates into the final result."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def rerank(
|
|
60
|
+
self,
|
|
61
|
+
query: str,
|
|
62
|
+
candidates: Sequence[RetrievedSkill],
|
|
63
|
+
*,
|
|
64
|
+
top_k: int,
|
|
65
|
+
) -> list[Skill]:
|
|
66
|
+
"""Return at most ``top_k`` of ``candidates`` in final relevance order."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DiscoveryProvider(ABC):
|
|
70
|
+
"""Extension point for indexed, bounded Skill discovery.
|
|
71
|
+
|
|
72
|
+
`SkillManager` keeps the provider's index in sync with the catalog and calls
|
|
73
|
+
`discover` from `SkillManager.discover`.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
async def index(self, skills: Sequence[Skill]) -> None:
|
|
78
|
+
"""Replace the discoverable catalog snapshot."""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
async def discover(
|
|
82
|
+
self,
|
|
83
|
+
query: str,
|
|
84
|
+
*,
|
|
85
|
+
top_k: int = 5,
|
|
86
|
+
filters: Mapping[str, Any] | None = None,
|
|
87
|
+
) -> list[Skill]:
|
|
88
|
+
"""Return at most ``top_k`` active candidates."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_filters(filters: Mapping[str, Any] | None) -> None:
|
|
92
|
+
"""Raise `SkillDiscoveryError` when ``filters`` uses an unsupported key.
|
|
93
|
+
|
|
94
|
+
Supported keys are ``name``, ``version``, ``tag`` and ``capability``.
|
|
95
|
+
"""
|
|
96
|
+
if filters:
|
|
97
|
+
unknown = sorted(set(filters) - _FILTER_KEYS)
|
|
98
|
+
if unknown:
|
|
99
|
+
raise SkillDiscoveryError(f"unsupported Skill discovery filters: {unknown}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def matches_filters(skill: Skill, filters: Mapping[str, Any] | None) -> bool:
|
|
103
|
+
"""Return whether ``skill`` is ``ACTIVE`` and satisfies every filter."""
|
|
104
|
+
if skill.lifecycle.state is not SkillLifecycleState.ACTIVE:
|
|
105
|
+
return False
|
|
106
|
+
if not filters:
|
|
107
|
+
return True
|
|
108
|
+
checks = {
|
|
109
|
+
"name": lambda expected: skill.metadata.name == expected,
|
|
110
|
+
"version": lambda expected: skill.metadata.version == expected,
|
|
111
|
+
"tag": lambda expected: expected in skill.metadata.tags,
|
|
112
|
+
"capability": lambda expected: any(item.name == expected for item in skill.capabilities),
|
|
113
|
+
}
|
|
114
|
+
return all(checks[key](expected) for key, expected in filters.items())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def skill_document(skill: Skill) -> str:
|
|
118
|
+
"""Return the text that retrievers index for ``skill``."""
|
|
119
|
+
return "\n".join(
|
|
120
|
+
(
|
|
121
|
+
skill.metadata.name,
|
|
122
|
+
skill.metadata.description,
|
|
123
|
+
" ".join(skill.metadata.tags),
|
|
124
|
+
"\n".join(skill.instructions),
|
|
125
|
+
" ".join(capability.name for capability in skill.capabilities),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _active(skills: Sequence[Skill]) -> tuple[Skill, ...]:
|
|
131
|
+
return tuple(skill for skill in skills if skill.lifecycle.state is SkillLifecycleState.ACTIVE)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _check_limit(limit: int) -> None:
|
|
135
|
+
if limit < 1:
|
|
136
|
+
raise SkillDiscoveryError("retrieval limit must be at least 1")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _top(retrieved: list[RetrievedSkill], limit: int) -> list[RetrievedSkill]:
|
|
140
|
+
retrieved.sort(key=lambda item: (-item.score, item.skill.skill_id))
|
|
141
|
+
return retrieved[:limit]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class LexicalSkillRetriever(SkillRetriever):
|
|
145
|
+
"""Deterministic inverted-index retrieval over Skill text.
|
|
146
|
+
|
|
147
|
+
The score is the number of distinct query terms found in a Skill. An empty
|
|
148
|
+
query matches every active Skill with score zero.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self) -> None:
|
|
152
|
+
self._skills: tuple[Skill, ...] = ()
|
|
153
|
+
self._tokens: dict[str, frozenset[str]] = {}
|
|
154
|
+
self._postings: dict[str, frozenset[str]] = {}
|
|
155
|
+
|
|
156
|
+
async def index(self, skills: Sequence[Skill]) -> None:
|
|
157
|
+
"""Tokenize every active Skill and rebuild the inverted index."""
|
|
158
|
+
active = _active(skills)
|
|
159
|
+
tokens = {
|
|
160
|
+
skill.skill_id: frozenset(_TOKEN_PATTERN.findall(skill_document(skill).lower()))
|
|
161
|
+
for skill in active
|
|
162
|
+
}
|
|
163
|
+
postings: dict[str, set[str]] = {}
|
|
164
|
+
for skill_id, skill_tokens in tokens.items():
|
|
165
|
+
for token in skill_tokens:
|
|
166
|
+
postings.setdefault(token, set()).add(skill_id)
|
|
167
|
+
self._skills = active
|
|
168
|
+
self._tokens = tokens
|
|
169
|
+
self._postings = {token: frozenset(ids) for token, ids in postings.items()}
|
|
170
|
+
|
|
171
|
+
async def retrieve(
|
|
172
|
+
self,
|
|
173
|
+
query: str,
|
|
174
|
+
*,
|
|
175
|
+
limit: int,
|
|
176
|
+
filters: Mapping[str, Any] | None = None,
|
|
177
|
+
) -> list[RetrievedSkill]:
|
|
178
|
+
"""Return the active Skills sharing the most terms with ``query``."""
|
|
179
|
+
_check_limit(limit)
|
|
180
|
+
validate_filters(filters)
|
|
181
|
+
terms = frozenset(_TOKEN_PATTERN.findall(query.lower()))
|
|
182
|
+
if terms:
|
|
183
|
+
ids = frozenset().union(*(self._postings.get(term, frozenset()) for term in terms))
|
|
184
|
+
candidates = [skill for skill in self._skills if skill.skill_id in ids]
|
|
185
|
+
else:
|
|
186
|
+
candidates = list(self._skills)
|
|
187
|
+
return _top(
|
|
188
|
+
[
|
|
189
|
+
RetrievedSkill(skill, float(len(terms & self._tokens[skill.skill_id])))
|
|
190
|
+
for skill in candidates
|
|
191
|
+
if matches_filters(skill, filters)
|
|
192
|
+
],
|
|
193
|
+
limit,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _unit(vector: Sequence[float]) -> tuple[float, ...]:
|
|
198
|
+
norm = math.sqrt(math.fsum(value * value for value in vector))
|
|
199
|
+
return tuple(value / norm for value in vector) if norm else tuple(0.0 for _ in vector)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SemanticSkillRetriever(SkillRetriever):
|
|
203
|
+
"""Dense cosine-similarity retrieval backed by an injected `Embedder`.
|
|
204
|
+
|
|
205
|
+
Any `mcp_capability_router.Embedder` works. With the ``langchain`` extra,
|
|
206
|
+
`skill_manager.integrations.langchain.LangChainEmbedder` adapts every
|
|
207
|
+
LangChain ``Embeddings``.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self, embedder: Embedder) -> None:
|
|
211
|
+
self._embedder = embedder
|
|
212
|
+
self._entries: tuple[tuple[Skill, tuple[float, ...]], ...] = ()
|
|
213
|
+
self._index_lock = asyncio.Lock()
|
|
214
|
+
|
|
215
|
+
async def index(self, skills: Sequence[Skill]) -> None:
|
|
216
|
+
"""Embed every active Skill and store unit-length vectors."""
|
|
217
|
+
active = _active(skills)
|
|
218
|
+
try:
|
|
219
|
+
vectors = await self._embedder.embed_documents([skill_document(s) for s in active])
|
|
220
|
+
except Exception as error:
|
|
221
|
+
raise SkillDiscoveryError("semantic Skill index embedding failed") from error
|
|
222
|
+
if len(vectors) != len(active):
|
|
223
|
+
raise SkillDiscoveryError("embedder returned a different number of vectors")
|
|
224
|
+
async with self._index_lock:
|
|
225
|
+
self._entries = tuple(
|
|
226
|
+
(skill, _unit(vector)) for skill, vector in zip(active, vectors, strict=True)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def retrieve(
|
|
230
|
+
self,
|
|
231
|
+
query: str,
|
|
232
|
+
*,
|
|
233
|
+
limit: int,
|
|
234
|
+
filters: Mapping[str, Any] | None = None,
|
|
235
|
+
) -> list[RetrievedSkill]:
|
|
236
|
+
"""Return the active Skills closest to ``query`` by cosine similarity."""
|
|
237
|
+
_check_limit(limit)
|
|
238
|
+
validate_filters(filters)
|
|
239
|
+
async with self._index_lock:
|
|
240
|
+
entries = self._entries
|
|
241
|
+
try:
|
|
242
|
+
query_vector = _unit(await self._embedder.embed_query(query))
|
|
243
|
+
except Exception as error:
|
|
244
|
+
raise SkillDiscoveryError("semantic Skill query embedding failed") from error
|
|
245
|
+
try:
|
|
246
|
+
return _top(
|
|
247
|
+
[
|
|
248
|
+
RetrievedSkill(
|
|
249
|
+
skill,
|
|
250
|
+
math.fsum(a * b for a, b in zip(query_vector, vector, strict=True)),
|
|
251
|
+
)
|
|
252
|
+
for skill, vector in entries
|
|
253
|
+
if matches_filters(skill, filters)
|
|
254
|
+
],
|
|
255
|
+
limit,
|
|
256
|
+
)
|
|
257
|
+
except ValueError as error:
|
|
258
|
+
raise SkillDiscoveryError("query and index vectors differ in dimension") from error
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ScoreSkillReranker(SkillReranker):
|
|
262
|
+
"""Order candidates by retrieval score, breaking ties by Skill ID."""
|
|
263
|
+
|
|
264
|
+
async def rerank(
|
|
265
|
+
self,
|
|
266
|
+
query: str,
|
|
267
|
+
candidates: Sequence[RetrievedSkill],
|
|
268
|
+
*,
|
|
269
|
+
top_k: int,
|
|
270
|
+
) -> list[Skill]:
|
|
271
|
+
"""Return the ``top_k`` highest-scoring Skills."""
|
|
272
|
+
del query
|
|
273
|
+
return [item.skill for item in _top(list(candidates), top_k)]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class PipelineDiscoveryProvider(DiscoveryProvider):
|
|
277
|
+
"""Compose a retriever and a reranker into bounded, verified discovery.
|
|
278
|
+
|
|
279
|
+
The provider asks the retriever for ``max(top_k, candidate_pool_size)``
|
|
280
|
+
candidates, reranks them, and checks that neither stage exceeded its limit,
|
|
281
|
+
invented a Skill, or returned an inactive or filtered-out Skill.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
retriever: Candidate source; defaults to `LexicalSkillRetriever`.
|
|
285
|
+
reranker: Final ordering; defaults to `ScoreSkillReranker`.
|
|
286
|
+
candidate_pool_size: Minimum number of candidates to rerank.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
retriever: SkillRetriever | None = None,
|
|
292
|
+
*,
|
|
293
|
+
reranker: SkillReranker | None = None,
|
|
294
|
+
candidate_pool_size: int = 50,
|
|
295
|
+
) -> None:
|
|
296
|
+
if candidate_pool_size < 1:
|
|
297
|
+
raise ValueError("candidate_pool_size must be at least 1")
|
|
298
|
+
self._retriever = retriever if retriever is not None else LexicalSkillRetriever()
|
|
299
|
+
self._reranker = reranker if reranker is not None else ScoreSkillReranker()
|
|
300
|
+
self._candidate_pool_size = candidate_pool_size
|
|
301
|
+
|
|
302
|
+
async def index(self, skills: Sequence[Skill]) -> None:
|
|
303
|
+
"""Forward the catalog snapshot to the retriever."""
|
|
304
|
+
await self._retriever.index(skills)
|
|
305
|
+
|
|
306
|
+
async def discover(
|
|
307
|
+
self,
|
|
308
|
+
query: str,
|
|
309
|
+
*,
|
|
310
|
+
top_k: int = 5,
|
|
311
|
+
filters: Mapping[str, Any] | None = None,
|
|
312
|
+
) -> list[Skill]:
|
|
313
|
+
"""Retrieve, rerank and verify at most ``top_k`` Skills."""
|
|
314
|
+
if top_k < 1:
|
|
315
|
+
raise SkillDiscoveryError("top_k must be at least 1")
|
|
316
|
+
validate_filters(filters)
|
|
317
|
+
limit = max(top_k, self._candidate_pool_size)
|
|
318
|
+
candidates = await self._retriever.retrieve(query, limit=limit, filters=filters)
|
|
319
|
+
if len(candidates) > limit:
|
|
320
|
+
raise SkillDiscoveryError("Skill retriever returned more than its candidate limit")
|
|
321
|
+
allowed = tuple(item for item in candidates if matches_filters(item.skill, filters))
|
|
322
|
+
ranked = await self._reranker.rerank(query, allowed, top_k=top_k)
|
|
323
|
+
if len(ranked) > top_k:
|
|
324
|
+
raise SkillDiscoveryError("Skill reranker returned more than top_k candidates")
|
|
325
|
+
by_id = {item.skill.skill_id: item.skill for item in allowed}
|
|
326
|
+
if len({skill.skill_id for skill in ranked}) != len(ranked) or any(
|
|
327
|
+
by_id.get(skill.skill_id) != skill for skill in ranked
|
|
328
|
+
):
|
|
329
|
+
raise SkillDiscoveryError("Skill reranker returned an invalid candidate set")
|
|
330
|
+
return ranked
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
__all__ = [
|
|
334
|
+
"DiscoveryProvider",
|
|
335
|
+
"LexicalSkillRetriever",
|
|
336
|
+
"PipelineDiscoveryProvider",
|
|
337
|
+
"RetrievedSkill",
|
|
338
|
+
"ScoreSkillReranker",
|
|
339
|
+
"SemanticSkillRetriever",
|
|
340
|
+
"SkillReranker",
|
|
341
|
+
"SkillRetriever",
|
|
342
|
+
"matches_filters",
|
|
343
|
+
"skill_document",
|
|
344
|
+
"validate_filters",
|
|
345
|
+
]
|
skill_manager/errors.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Skill Manager exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Every expected failure derives from `SkillManagerError`, so callers can catch
|
|
4
|
+
one base class or a precise subclass.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SkillManagerError(Exception):
|
|
11
|
+
"""Base class for expected Skill Manager failures."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OptionalDependencyError(SkillManagerError, ImportError):
|
|
15
|
+
"""An integration needs an extra that is not installed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SkillValidationError(SkillManagerError):
|
|
19
|
+
"""A Skill document or domain value is invalid."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SkillNotFoundError(SkillManagerError):
|
|
23
|
+
"""No Skill matches the requested immutable ID or name and version."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SkillSourceError(SkillManagerError):
|
|
27
|
+
"""A Skill source failed to load, persist, or delete a Skill."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SkillDiscoveryError(SkillManagerError):
|
|
31
|
+
"""Discovery or ranking failed."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SkillDependencyError(SkillManagerError):
|
|
35
|
+
"""A dependency is missing or its version constraint cannot be satisfied."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SkillDependencyCycleError(SkillDependencyError):
|
|
39
|
+
"""The dependency graph contains a cycle.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
path: Skill names along the cycle, starting and ending with the same name.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, path: tuple[str, ...]) -> None:
|
|
46
|
+
self.path = path
|
|
47
|
+
super().__init__("Skill dependency cycle: " + " -> ".join(path))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SkillConflictError(SkillManagerError):
|
|
51
|
+
"""Two catalog entries conflict or an update is stale."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CapabilityResolutionError(SkillManagerError):
|
|
55
|
+
"""A required MCP capability could not be resolved."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SkillCreationDisabledError(SkillManagerError):
|
|
59
|
+
"""Agent Skill creation is disabled by policy."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SkillGenerationError(SkillManagerError):
|
|
63
|
+
"""A `SkillGenerator` could not produce a valid Skill."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SkillGovernanceError(SkillManagerError):
|
|
67
|
+
"""A governance policy does not permit the requested operation."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SkillApprovalError(SkillManagerError):
|
|
71
|
+
"""Approval could not be completed or persisted."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SkillRejectionError(SkillManagerError):
|
|
75
|
+
"""Rejection could not be completed or persisted."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SkillLifecycleError(SkillManagerError):
|
|
79
|
+
"""A Skill lifecycle transition is invalid."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SkillBundleError(SkillManagerError):
|
|
83
|
+
"""A SkillBundle could not be built."""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Framework-agnostic contract for agent Skill generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from skill_manager.models import Skill
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class SkillFeedback:
|
|
18
|
+
"""One rejection recorded against a parent Skill revision.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
feedback_id: The feedback-manager event ID.
|
|
22
|
+
payload: The rejection payload, including the reviewer's ``feedback`` text.
|
|
23
|
+
metadata: Reviewer-supplied metadata.
|
|
24
|
+
created_at: When the rejection was submitted.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
feedback_id: str
|
|
28
|
+
payload: Mapping[str, Any]
|
|
29
|
+
metadata: Mapping[str, Any]
|
|
30
|
+
created_at: datetime
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class SkillGenerationRequest:
|
|
35
|
+
"""Everything a `SkillGenerator` receives for one generation.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
input: The caller's task description, prompt or structured input.
|
|
39
|
+
parent: The rejected revision being improved, if any.
|
|
40
|
+
feedback: Rejections of ``parent``, newest first.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
input: Any
|
|
44
|
+
parent: Skill | None = None
|
|
45
|
+
feedback: tuple[SkillFeedback, ...] = field(default=())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SkillGenerator(ABC):
|
|
49
|
+
"""Extension point that turns a request into a candidate Skill.
|
|
50
|
+
|
|
51
|
+
Implement it with any model, agent framework or rule engine.
|
|
52
|
+
`SkillManager.generate_skill` stamps agent provenance, lineage and the
|
|
53
|
+
lifecycle state on the result, then validates and registers it, so a
|
|
54
|
+
generator only needs to produce the Skill content. With the ``langchain``
|
|
55
|
+
extra, `skill_manager.integrations.langchain.XStructuredSkillGenerator`
|
|
56
|
+
wraps any LangChain chat model.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
```python
|
|
60
|
+
class TemplateGenerator(SkillGenerator):
|
|
61
|
+
async def generate(self, request):
|
|
62
|
+
return Skill(
|
|
63
|
+
metadata=SkillMetadata(name="echo", version="1.0.0", description="Echo."),
|
|
64
|
+
instructions=(f"Repeat: {request.input}",),
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def name(self) -> str:
|
|
71
|
+
"""Return the provenance source recorded on generated Skills."""
|
|
72
|
+
return type(self).__name__
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
async def generate(self, request: SkillGenerationRequest) -> Skill:
|
|
76
|
+
"""Return one candidate Skill.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
SkillGenerationError: If no valid Skill can be produced.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = ["SkillFeedback", "SkillGenerationRequest", "SkillGenerator"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional framework integrations; each module needs its matching extra."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""LangChain integration: xstructured Skill generation and embeddings.
|
|
2
|
+
|
|
3
|
+
Install with ``pip install 'agskillmanager[langchain]'``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from pydantic import Field, model_validator
|
|
13
|
+
|
|
14
|
+
from skill_manager._optional import optional_import
|
|
15
|
+
from skill_manager.errors import SkillGenerationError
|
|
16
|
+
from skill_manager.generation import SkillGenerationRequest, SkillGenerator
|
|
17
|
+
from skill_manager.models import (
|
|
18
|
+
FrozenModel,
|
|
19
|
+
Skill,
|
|
20
|
+
SkillCapability,
|
|
21
|
+
SkillDependency,
|
|
22
|
+
SkillMetadata,
|
|
23
|
+
SkillReference,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from langchain_core.runnables import Runnable
|
|
28
|
+
|
|
29
|
+
_xstructured = optional_import("xstructured", "langchain")
|
|
30
|
+
LangChainEmbedder = optional_import(
|
|
31
|
+
"mcp_capability_router.integrations.langchain", "langchain"
|
|
32
|
+
).LangChainEmbedder
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _feedback_payload(request: SkillGenerationRequest) -> list[dict[str, Any]]:
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
"feedback": dict(item.payload),
|
|
39
|
+
"metadata": dict(item.metadata),
|
|
40
|
+
"created_at": item.created_at.isoformat(),
|
|
41
|
+
}
|
|
42
|
+
for item in request.feedback
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _SkillDraft(FrozenModel):
|
|
47
|
+
"""The model-authored part of a Skill.
|
|
48
|
+
|
|
49
|
+
The ID, revision, provenance, lifecycle and governance belong to the
|
|
50
|
+
manager, so the model is never asked to invent them.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
metadata: SkillMetadata
|
|
54
|
+
instructions: tuple[str, ...]
|
|
55
|
+
dependencies: tuple[SkillDependency, ...] = ()
|
|
56
|
+
capabilities: tuple[SkillCapability, ...] = ()
|
|
57
|
+
resources: tuple[SkillReference, ...] = ()
|
|
58
|
+
prompts: dict[str, str] = Field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
@model_validator(mode="after")
|
|
61
|
+
def validate_as_skill(self) -> _SkillDraft:
|
|
62
|
+
"""Apply every Skill rule, so xstructured repair sees the violations."""
|
|
63
|
+
self.to_skill()
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def to_skill(self) -> Skill:
|
|
67
|
+
"""Return a new Skill revision built from the draft."""
|
|
68
|
+
return Skill(**dict(self))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class XStructuredSkillGenerator(SkillGenerator):
|
|
72
|
+
"""Generate a Skill from any LangChain runnable through xstructured.
|
|
73
|
+
|
|
74
|
+
xstructured injects the authorable Skill fields (metadata, instructions,
|
|
75
|
+
dependencies, capabilities, resources and prompts) into the prompt,
|
|
76
|
+
extracts and validates the JSON answer, and optionally repairs it with
|
|
77
|
+
``repair``. The manager assigns the ID, revision and governance.
|
|
78
|
+
When the request revises a rejected parent, the parent Skill and the
|
|
79
|
+
reviewer feedback are added to the input: as ``parent_skill`` and
|
|
80
|
+
``previous_skill_feedback`` keys for mapping inputs, or as appended text
|
|
81
|
+
otherwise. Model and transport errors propagate unchanged.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
runnable: The model or chain, for example ``ChatOpenAI(...)``.
|
|
85
|
+
repair: Optional runnable that repairs invalid output.
|
|
86
|
+
**options: Any other `xstructured.with_xstructured_output` option,
|
|
87
|
+
such as ``parser_config``, ``repair_config`` or ``observer``.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
runnable: Runnable[Any, Any],
|
|
93
|
+
*,
|
|
94
|
+
repair: Runnable[Any, Any] | None = None,
|
|
95
|
+
**options: Any,
|
|
96
|
+
) -> None:
|
|
97
|
+
self._structured = _xstructured.with_xstructured_output(
|
|
98
|
+
runnable, _SkillDraft, repair=repair, **options
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def name(self) -> str:
|
|
103
|
+
"""Return ``xstructured``."""
|
|
104
|
+
return "xstructured"
|
|
105
|
+
|
|
106
|
+
async def generate(self, request: SkillGenerationRequest) -> Skill:
|
|
107
|
+
"""Run the structured runnable and return the extracted Skill."""
|
|
108
|
+
try:
|
|
109
|
+
result = await self._structured.ainvoke(self._input(request))
|
|
110
|
+
except _xstructured.XStructuredError as error:
|
|
111
|
+
raise SkillGenerationError("xstructured could not extract a valid Skill") from error
|
|
112
|
+
if not isinstance(result.structured, _SkillDraft):
|
|
113
|
+
raise SkillGenerationError("xstructured returned a value that is not a valid Skill")
|
|
114
|
+
return result.structured.to_skill()
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _input(request: SkillGenerationRequest) -> Any:
|
|
118
|
+
if request.parent is None:
|
|
119
|
+
return request.input
|
|
120
|
+
parent = request.parent.model_dump(mode="json")
|
|
121
|
+
feedback = _feedback_payload(request)
|
|
122
|
+
if isinstance(request.input, Mapping):
|
|
123
|
+
return {**request.input, "parent_skill": parent, "previous_skill_feedback": feedback}
|
|
124
|
+
return (
|
|
125
|
+
f"{request.input}\n\nRevise this rejected Skill:\n{json.dumps(parent)}"
|
|
126
|
+
f"\n\nReviewer feedback:\n{json.dumps(feedback)}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = ["LangChainEmbedder", "XStructuredSkillGenerator"]
|