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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Deterministic dependency resolution and SkillBundle composition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from packaging.specifiers import SpecifierSet
|
|
9
|
+
from packaging.version import Version
|
|
10
|
+
|
|
11
|
+
from skill_manager.errors import (
|
|
12
|
+
SkillConflictError,
|
|
13
|
+
SkillDependencyCycleError,
|
|
14
|
+
SkillDependencyError,
|
|
15
|
+
)
|
|
16
|
+
from skill_manager.models import Skill, SkillDependency, SkillLifecycleState
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class _Requirement:
|
|
24
|
+
parent: Skill
|
|
25
|
+
dependency: SkillDependency
|
|
26
|
+
path: tuple[str, ...]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_dependencies(roots: Iterable[Skill], catalog: Iterable[Skill]) -> tuple[Skill, ...]:
|
|
30
|
+
"""Resolve the transitive dependencies of ``roots`` against the active ``catalog``.
|
|
31
|
+
|
|
32
|
+
The search prefers the highest matching version of each dependency and
|
|
33
|
+
backtracks when a later constraint rules it out. One revision is chosen per
|
|
34
|
+
Skill name, shared by every Skill that depends on it.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The selected Skills in dependency-first order, ties broken by name,
|
|
38
|
+
version and Skill ID, so equal inputs always produce equal output.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
SkillDependencyError: If a dependency has no matching active revision.
|
|
42
|
+
SkillDependencyCycleError: If the selected Skills form a cycle.
|
|
43
|
+
SkillConflictError: If two roots share a name or constraints conflict.
|
|
44
|
+
"""
|
|
45
|
+
available = tuple(
|
|
46
|
+
skill for skill in catalog if skill.lifecycle.state is SkillLifecycleState.ACTIVE
|
|
47
|
+
)
|
|
48
|
+
sorted_roots = tuple(
|
|
49
|
+
sorted(
|
|
50
|
+
roots,
|
|
51
|
+
key=lambda item: (item.metadata.name, Version(item.metadata.version), item.skill_id),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
assignments: dict[str, Skill] = {}
|
|
55
|
+
for root in sorted_roots:
|
|
56
|
+
existing = assignments.get(root.metadata.name)
|
|
57
|
+
if existing is not None and existing.skill_id != root.skill_id:
|
|
58
|
+
raise SkillConflictError(
|
|
59
|
+
f"multiple root revisions selected for Skill {root.metadata.name!r}"
|
|
60
|
+
)
|
|
61
|
+
assignments[root.metadata.name] = root
|
|
62
|
+
|
|
63
|
+
def matching(dependency: SkillDependency) -> list[Skill]:
|
|
64
|
+
spec = SpecifierSet("" if dependency.version == "*" else dependency.version)
|
|
65
|
+
matches = [
|
|
66
|
+
skill
|
|
67
|
+
for skill in available
|
|
68
|
+
if skill.metadata.name == dependency.name and Version(skill.metadata.version) in spec
|
|
69
|
+
]
|
|
70
|
+
return sorted(
|
|
71
|
+
matches,
|
|
72
|
+
key=lambda item: (Version(item.metadata.version), item.skill_id),
|
|
73
|
+
reverse=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def enqueue_children(
|
|
77
|
+
skill: Skill,
|
|
78
|
+
path: tuple[str, ...],
|
|
79
|
+
) -> tuple[_Requirement, ...]:
|
|
80
|
+
return tuple(
|
|
81
|
+
_Requirement(skill, dependency, path)
|
|
82
|
+
for dependency in sorted(skill.dependencies, key=lambda item: item.name)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def topological_order(
|
|
86
|
+
chosen: dict[str, Skill],
|
|
87
|
+
edges: dict[str, set[str]],
|
|
88
|
+
) -> tuple[Skill, ...]:
|
|
89
|
+
by_id = {skill.skill_id: skill for skill in chosen.values()}
|
|
90
|
+
visiting: list[str] = []
|
|
91
|
+
visited: set[str] = set()
|
|
92
|
+
result: list[Skill] = []
|
|
93
|
+
|
|
94
|
+
def visit(skill_id: str) -> None:
|
|
95
|
+
if skill_id in visiting:
|
|
96
|
+
start = visiting.index(skill_id)
|
|
97
|
+
cycle_ids = (*visiting[start:], skill_id)
|
|
98
|
+
raise SkillDependencyCycleError(
|
|
99
|
+
tuple(by_id[item].metadata.name for item in cycle_ids)
|
|
100
|
+
)
|
|
101
|
+
if skill_id in visited:
|
|
102
|
+
return
|
|
103
|
+
visiting.append(skill_id)
|
|
104
|
+
skill = by_id[skill_id]
|
|
105
|
+
children = sorted(
|
|
106
|
+
edges.get(skill_id, set()),
|
|
107
|
+
key=lambda item: (
|
|
108
|
+
by_id[item].metadata.name,
|
|
109
|
+
Version(by_id[item].metadata.version),
|
|
110
|
+
item,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
for child_id in children:
|
|
114
|
+
visit(child_id)
|
|
115
|
+
visiting.pop()
|
|
116
|
+
visited.add(skill_id)
|
|
117
|
+
result.append(skill)
|
|
118
|
+
|
|
119
|
+
for skill in sorted(
|
|
120
|
+
by_id.values(),
|
|
121
|
+
key=lambda item: (
|
|
122
|
+
item.metadata.name,
|
|
123
|
+
Version(item.metadata.version),
|
|
124
|
+
item.skill_id,
|
|
125
|
+
),
|
|
126
|
+
):
|
|
127
|
+
visit(skill.skill_id)
|
|
128
|
+
return tuple(result)
|
|
129
|
+
|
|
130
|
+
def search(
|
|
131
|
+
pending: tuple[_Requirement, ...],
|
|
132
|
+
chosen: dict[str, Skill],
|
|
133
|
+
edges: dict[str, set[str]],
|
|
134
|
+
) -> tuple[Skill, ...]:
|
|
135
|
+
if not pending:
|
|
136
|
+
return topological_order(chosen, edges)
|
|
137
|
+
|
|
138
|
+
requirement, remaining = pending[0], pending[1:]
|
|
139
|
+
dependency = requirement.dependency
|
|
140
|
+
path = (*requirement.path, requirement.parent.metadata.name, dependency.name)
|
|
141
|
+
options = matching(dependency)
|
|
142
|
+
if not options:
|
|
143
|
+
raise SkillDependencyError(
|
|
144
|
+
"missing dependency along path "
|
|
145
|
+
+ " -> ".join(path)
|
|
146
|
+
+ f" (requires {dependency.version})"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
selected = chosen.get(dependency.name)
|
|
150
|
+
if selected is not None:
|
|
151
|
+
spec = SpecifierSet("" if dependency.version == "*" else dependency.version)
|
|
152
|
+
if Version(selected.metadata.version) not in spec:
|
|
153
|
+
raise SkillConflictError(
|
|
154
|
+
"conflicting dependency constraints along path " + " -> ".join(path)
|
|
155
|
+
)
|
|
156
|
+
next_edges = {key: set(value) for key, value in edges.items()}
|
|
157
|
+
next_edges.setdefault(requirement.parent.skill_id, set()).add(selected.skill_id)
|
|
158
|
+
return search(remaining, chosen, next_edges)
|
|
159
|
+
|
|
160
|
+
failures: list[SkillDependencyError | SkillConflictError] = []
|
|
161
|
+
for candidate in options:
|
|
162
|
+
next_chosen = dict(chosen)
|
|
163
|
+
next_chosen[dependency.name] = candidate
|
|
164
|
+
next_edges = {key: set(value) for key, value in edges.items()}
|
|
165
|
+
next_edges.setdefault(requirement.parent.skill_id, set()).add(candidate.skill_id)
|
|
166
|
+
next_pending = (
|
|
167
|
+
*enqueue_children(
|
|
168
|
+
candidate,
|
|
169
|
+
(*requirement.path, requirement.parent.metadata.name),
|
|
170
|
+
),
|
|
171
|
+
*remaining,
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
return search(next_pending, next_chosen, next_edges)
|
|
175
|
+
except (SkillDependencyError, SkillConflictError) as error:
|
|
176
|
+
failures.append(error)
|
|
177
|
+
raise failures[0]
|
|
178
|
+
|
|
179
|
+
pending = tuple(
|
|
180
|
+
requirement for root in sorted_roots for requirement in enqueue_children(root, ())
|
|
181
|
+
)
|
|
182
|
+
initial_edges = {root.skill_id: set() for root in sorted_roots}
|
|
183
|
+
return search(
|
|
184
|
+
pending,
|
|
185
|
+
assignments,
|
|
186
|
+
initial_edges,
|
|
187
|
+
)
|
skill_manager/sources.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Skill source contract and a safe filesystem implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tempfile import NamedTemporaryFile
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from skill_manager.errors import SkillSourceError
|
|
14
|
+
from skill_manager.models import Skill, is_skill_id
|
|
15
|
+
from skill_manager.validation import dump_skill_yaml
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
|
|
20
|
+
_SKILL_ID_LINE = re.compile(r"""^skill_id:\s*["']?(sk_[0-9A-HJKMNP-TV-Z]{26})["']?\s*$""", re.M)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_source_id(source_id: str) -> str:
|
|
24
|
+
"""Return ``source_id`` when it is a non-empty, single-line identifier of at most 128 chars."""
|
|
25
|
+
if (
|
|
26
|
+
not source_id.strip()
|
|
27
|
+
or len(source_id) > 128
|
|
28
|
+
or any(character in source_id for character in "\r\n\x00")
|
|
29
|
+
):
|
|
30
|
+
raise ValueError("source_id must be a non-empty safe identifier")
|
|
31
|
+
return source_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SkillSource(ABC):
|
|
35
|
+
"""Where Skills originate; each source owns its own persistence.
|
|
36
|
+
|
|
37
|
+
A source stores Skill YAML documents. `SkillManager` parses and validates
|
|
38
|
+
them, so a source never interprets YAML itself. Every implementation must
|
|
39
|
+
keep these promises, which `skill_manager.testing.SkillSourceContract`
|
|
40
|
+
verifies:
|
|
41
|
+
|
|
42
|
+
- `load` returns every stored document keyed by a stable locator.
|
|
43
|
+
- `save` creates or overwrites the document of ``skill.skill_id``, and the
|
|
44
|
+
next `load` returns it exactly once.
|
|
45
|
+
- `delete` removes the document and ignores unknown IDs.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def source_id(self) -> str:
|
|
51
|
+
"""Return a stable, non-secret source identifier."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def load(self) -> Mapping[str, str]:
|
|
55
|
+
"""Return locator to YAML text, without parsing or executing it."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
async def save(self, skill: Skill) -> None:
|
|
59
|
+
"""Persist one validated Skill revision, replacing its previous document."""
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
async def delete(self, skill_id: str) -> None:
|
|
63
|
+
"""Remove the document of a Skill; unknown IDs are ignored."""
|
|
64
|
+
|
|
65
|
+
async def aclose(self) -> None: # noqa: B027 - optional hook with a no-op default
|
|
66
|
+
"""Release resources; the default does nothing."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FilesystemSkillSource(SkillSource):
|
|
70
|
+
"""Read and atomically write ``*.yaml`` and ``*.yml`` files in one directory.
|
|
71
|
+
|
|
72
|
+
Symbolic links and paths escaping the root are refused. New Skills are
|
|
73
|
+
written to ``<skill_id>.yaml``; a Skill loaded from another file name is
|
|
74
|
+
rewritten in place.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
root: Directory holding the Skill documents; created on first save.
|
|
78
|
+
source_id: Stable identifier for this source.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, root: str | Path, *, source_id: str = "filesystem") -> None:
|
|
82
|
+
self._root = Path(root).expanduser().resolve()
|
|
83
|
+
self._source_id = validate_source_id(source_id)
|
|
84
|
+
self._paths: dict[str, Path] = {}
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def source_id(self) -> str:
|
|
88
|
+
"""Return the configured source identifier."""
|
|
89
|
+
return self._source_id
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def root(self) -> Path:
|
|
93
|
+
"""Return the resolved source directory."""
|
|
94
|
+
return self._root
|
|
95
|
+
|
|
96
|
+
async def load(self) -> Mapping[str, str]:
|
|
97
|
+
"""Read every Skill document in the root directory."""
|
|
98
|
+
try:
|
|
99
|
+
documents, paths = await asyncio.to_thread(self._load_sync)
|
|
100
|
+
except OSError as error:
|
|
101
|
+
raise SkillSourceError(
|
|
102
|
+
f"cannot load filesystem Skill source {self.source_id!r}"
|
|
103
|
+
) from error
|
|
104
|
+
self._paths = paths
|
|
105
|
+
return documents
|
|
106
|
+
|
|
107
|
+
def _safe(self, path: Path) -> Path:
|
|
108
|
+
if path.is_symlink():
|
|
109
|
+
raise SkillSourceError(f"symbolic links are not allowed in Skill sources: {path.name}")
|
|
110
|
+
resolved = path.resolve()
|
|
111
|
+
if not resolved.is_relative_to(self._root):
|
|
112
|
+
raise SkillSourceError(f"Skill path escapes configured root: {path.name}")
|
|
113
|
+
return resolved
|
|
114
|
+
|
|
115
|
+
def _load_sync(self) -> tuple[dict[str, str], dict[str, Path]]:
|
|
116
|
+
if not self._root.exists():
|
|
117
|
+
return {}, {}
|
|
118
|
+
if not self._root.is_dir():
|
|
119
|
+
raise SkillSourceError(f"Skill source root is not a directory: {self._root}")
|
|
120
|
+
documents: dict[str, str] = {}
|
|
121
|
+
paths: dict[str, Path] = {}
|
|
122
|
+
for path in sorted((*self._root.glob("*.yaml"), *self._root.glob("*.yml"))):
|
|
123
|
+
text = self._safe(path).read_text(encoding="utf-8")
|
|
124
|
+
documents[path.name] = text
|
|
125
|
+
if match := _SKILL_ID_LINE.search(text):
|
|
126
|
+
paths.setdefault(match.group(1), path)
|
|
127
|
+
return documents, paths
|
|
128
|
+
|
|
129
|
+
async def save(self, skill: Skill) -> None:
|
|
130
|
+
"""Atomically write the Skill document."""
|
|
131
|
+
destination = self._paths.get(skill.skill_id, self._root / f"{skill.skill_id}.yaml")
|
|
132
|
+
try:
|
|
133
|
+
await asyncio.to_thread(self._save_sync, destination, dump_skill_yaml(skill))
|
|
134
|
+
except OSError as error:
|
|
135
|
+
raise SkillSourceError(f"cannot persist Skill {skill.skill_id!r}") from error
|
|
136
|
+
self._paths[skill.skill_id] = destination
|
|
137
|
+
|
|
138
|
+
def _save_sync(self, destination: Path, serialized: str) -> None:
|
|
139
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
if destination.exists():
|
|
141
|
+
self._safe(destination)
|
|
142
|
+
temporary = NamedTemporaryFile( # noqa: SIM115 - cleanup must also cover __enter__ failures
|
|
143
|
+
mode="w",
|
|
144
|
+
encoding="utf-8",
|
|
145
|
+
dir=self._root,
|
|
146
|
+
prefix=".skill-",
|
|
147
|
+
suffix=".tmp",
|
|
148
|
+
delete=False,
|
|
149
|
+
)
|
|
150
|
+
temporary_path = Path(temporary.name)
|
|
151
|
+
try:
|
|
152
|
+
with temporary:
|
|
153
|
+
temporary.write(serialized)
|
|
154
|
+
temporary.flush()
|
|
155
|
+
os.fsync(temporary.fileno())
|
|
156
|
+
temporary_path.replace(destination)
|
|
157
|
+
finally:
|
|
158
|
+
temporary_path.unlink(missing_ok=True)
|
|
159
|
+
|
|
160
|
+
async def delete(self, skill_id: str) -> None:
|
|
161
|
+
"""Delete the Skill document, if present."""
|
|
162
|
+
if not is_skill_id(skill_id):
|
|
163
|
+
raise SkillSourceError("invalid Skill ID for filesystem deletion")
|
|
164
|
+
path = self._paths.pop(skill_id, self._root / f"{skill_id}.yaml")
|
|
165
|
+
try:
|
|
166
|
+
await asyncio.to_thread(self._delete_sync, path)
|
|
167
|
+
except OSError as error:
|
|
168
|
+
raise SkillSourceError(f"cannot delete Skill {skill_id!r}") from error
|
|
169
|
+
|
|
170
|
+
def _delete_sync(self, path: Path) -> None:
|
|
171
|
+
if path.exists() or path.is_symlink():
|
|
172
|
+
self._safe(path).unlink()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
__all__ = ["FilesystemSkillSource", "SkillSource", "validate_source_id"]
|