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