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,307 @@
1
+ """Immutable, validated Skill domain models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import secrets
7
+ from datetime import UTC, datetime
8
+ from enum import StrEnum
9
+ from typing import Annotated
10
+ from urllib.parse import urlsplit
11
+
12
+ from mcp_capability_router import Capability
13
+ from packaging.specifiers import SpecifierSet
14
+ from packaging.version import InvalidVersion, Version
15
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
16
+
17
+ _ID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
18
+ _ID_PATTERN = re.compile(r"^sk_[0-9A-HJKMNP-TV-Z]{26}$")
19
+ _NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62})$")
20
+ _SECRET_KEY_PATTERN = re.compile(
21
+ r"(?:secret|password|token|credential|api[_-]?key|authorization)", re.I
22
+ )
23
+
24
+
25
+ def is_skill_id(value: str) -> bool:
26
+ """Return whether ``value`` is a well-formed immutable Skill ID."""
27
+ return _ID_PATTERN.fullmatch(value) is not None
28
+
29
+
30
+ def _new_skill_id() -> str:
31
+ value = secrets.randbits(128)
32
+ chars = ["0"] * 26
33
+ for index in range(25, -1, -1):
34
+ chars[index] = _ID_ALPHABET[value & 31]
35
+ value >>= 5
36
+ return "sk_" + "".join(chars)
37
+
38
+
39
+ class FrozenModel(BaseModel):
40
+ """Base for frozen, extra-forbid public domain models."""
41
+
42
+ model_config = ConfigDict(frozen=True, extra="forbid")
43
+
44
+
45
+ class SkillLifecycleState(StrEnum):
46
+ """Supported Skill lifecycle states."""
47
+
48
+ ACTIVE = "ACTIVE"
49
+ PENDING_APPROVAL = "PENDING_APPROVAL"
50
+ REJECTED = "REJECTED"
51
+
52
+
53
+ class SkillOrigin(StrEnum):
54
+ """Origin category for a Skill revision."""
55
+
56
+ HUMAN = "human"
57
+ AGENT = "agent"
58
+ IMPORTED = "imported"
59
+ SYSTEM = "system"
60
+
61
+
62
+ class SkillMetadata(FrozenModel):
63
+ """Human-readable identity and semantic version metadata."""
64
+
65
+ name: str
66
+ version: str
67
+ description: str
68
+ tags: tuple[str, ...] = ()
69
+
70
+ @field_validator("name")
71
+ @classmethod
72
+ def validate_name(cls, value: str) -> str:
73
+ """Require a lowercase hyphenated name of at most 63 characters."""
74
+ if not _NAME_PATTERN.fullmatch(value):
75
+ raise ValueError("name must be lowercase alphanumeric words separated by hyphens")
76
+ return value
77
+
78
+ @field_validator("version")
79
+ @classmethod
80
+ def validate_version(cls, value: str) -> str:
81
+ """Require a PEP 440 version."""
82
+ try:
83
+ Version(value)
84
+ except InvalidVersion as error:
85
+ raise ValueError("version must be a valid PEP 440 version") from error
86
+ return value
87
+
88
+ @field_validator("description")
89
+ @classmethod
90
+ def validate_description(cls, value: str) -> str:
91
+ """Require 1 to 4000 characters and strip surrounding whitespace."""
92
+ if not value.strip() or len(value) > 4000:
93
+ raise ValueError("description must contain 1 to 4000 characters")
94
+ return value.strip()
95
+
96
+
97
+ class SkillDependency(FrozenModel):
98
+ """A named Skill requirement with a PEP 440 version constraint."""
99
+
100
+ name: str
101
+ version: str = "*"
102
+
103
+ @field_validator("name")
104
+ @classmethod
105
+ def validate_name(cls, value: str) -> str:
106
+ """Require a valid Skill name."""
107
+ if not _NAME_PATTERN.fullmatch(value):
108
+ raise ValueError("dependency name is invalid")
109
+ return value
110
+
111
+ @field_validator("version")
112
+ @classmethod
113
+ def validate_version(cls, value: str) -> str:
114
+ """Require ``*`` or a PEP 440 specifier set."""
115
+ if value != "*":
116
+ SpecifierSet(value)
117
+ return value
118
+
119
+
120
+ class SkillCapability(FrozenModel):
121
+ """A required MCP capability expressed by its canonical name."""
122
+
123
+ name: str
124
+
125
+ @field_validator("name")
126
+ @classmethod
127
+ def validate_name(cls, value: str) -> str:
128
+ """Require 1 to 200 safe capability-name characters."""
129
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,199}", value):
130
+ raise ValueError("capability name must contain 1 to 200 safe characters")
131
+ return value
132
+
133
+
134
+ class SkillReference(FrozenModel):
135
+ """A safe, source-relative resource reference."""
136
+
137
+ name: str
138
+ path: str
139
+
140
+ @field_validator("name")
141
+ @classmethod
142
+ def validate_name(cls, value: str) -> str:
143
+ """Require a valid resource name."""
144
+ if not _NAME_PATTERN.fullmatch(value):
145
+ raise ValueError("resource name is invalid")
146
+ return value
147
+
148
+ @field_validator("path")
149
+ @classmethod
150
+ def validate_path(cls, value: str) -> str:
151
+ """Require a relative POSIX path without traversal, drive or scheme."""
152
+ if not value or "\\" in value or "\x00" in value or value.startswith(("/", "~")):
153
+ raise ValueError("resource path must be a safe relative POSIX path")
154
+ parts = value.split("/")
155
+ if any(part in {"", ".", ".."} for part in parts):
156
+ raise ValueError("resource path may not contain empty, '.' or '..' segments")
157
+ if ":" in parts[0]:
158
+ raise ValueError("resource path may not include a drive or URI scheme")
159
+ return value
160
+
161
+
162
+ class SkillProvenance(FrozenModel):
163
+ """Origin and lineage facts for this immutable Skill revision."""
164
+
165
+ origin: SkillOrigin = SkillOrigin.HUMAN
166
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
167
+ source: str | None = None
168
+ generation_context: dict[str, str] = Field(default_factory=dict)
169
+ parent_skill_ids: tuple[str, ...] = ()
170
+
171
+ @field_validator("created_at")
172
+ @classmethod
173
+ def require_timezone(cls, value: datetime) -> datetime:
174
+ """Require a timezone-aware timestamp."""
175
+ if value.tzinfo is None or value.utcoffset() is None:
176
+ raise ValueError("created_at must be timezone-aware")
177
+ return value
178
+
179
+ @field_validator("source")
180
+ @classmethod
181
+ def reject_credential_urls(cls, value: str | None) -> str | None:
182
+ """Reject over-long sources and URLs with embedded credentials."""
183
+ if value is None:
184
+ return value
185
+ if len(value) > 1024:
186
+ raise ValueError("provenance source is too long")
187
+ parsed = urlsplit(value)
188
+ if parsed.username is not None or parsed.password is not None:
189
+ raise ValueError("provenance source may not contain URL credentials")
190
+ return value
191
+
192
+ @field_validator("generation_context")
193
+ @classmethod
194
+ def reject_secret_keys(cls, value: dict[str, str]) -> dict[str, str]:
195
+ """Reject context keys that name credentials or secrets."""
196
+ if any(_SECRET_KEY_PATTERN.search(key) for key in value):
197
+ raise ValueError("generation_context may not serialize credentials or secrets")
198
+ return value
199
+
200
+ @field_validator("parent_skill_ids")
201
+ @classmethod
202
+ def validate_parent_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]:
203
+ """Require every parent to be a well-formed Skill ID."""
204
+ if any(not _ID_PATTERN.fullmatch(item) for item in value):
205
+ raise ValueError("parent Skill IDs must use the immutable Skill ID format")
206
+ return value
207
+
208
+
209
+ class SkillLifecycle(FrozenModel):
210
+ """Current lifecycle state and transition timestamp."""
211
+
212
+ state: SkillLifecycleState = SkillLifecycleState.ACTIVE
213
+ changed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
214
+ reason: str | None = None
215
+
216
+
217
+ class SkillGovernance(FrozenModel):
218
+ """Approval and rejection audit references owned by the Skill domain."""
219
+
220
+ approval_event_id: str | None = None
221
+ rejection_event_id: str | None = None
222
+ reviewer: str | None = None
223
+ reason: str | None = None
224
+
225
+
226
+ class Skill(FrozenModel):
227
+ """Canonical, immutable Skill revision."""
228
+
229
+ api_version: str = "1.0"
230
+ kind: str = "skill"
231
+ skill_id: str = Field(default_factory=_new_skill_id)
232
+ revision: Annotated[int, Field(ge=1)] = 1
233
+ metadata: SkillMetadata
234
+ instructions: tuple[str, ...]
235
+ dependencies: tuple[SkillDependency, ...] = ()
236
+ capabilities: tuple[SkillCapability, ...] = ()
237
+ resources: tuple[SkillReference, ...] = ()
238
+ prompts: dict[str, str] = Field(default_factory=dict)
239
+ provenance: SkillProvenance = Field(default_factory=SkillProvenance)
240
+ lifecycle: SkillLifecycle = Field(default_factory=SkillLifecycle)
241
+ governance: SkillGovernance = Field(default_factory=SkillGovernance)
242
+
243
+ @field_validator("skill_id")
244
+ @classmethod
245
+ def validate_id(cls, value: str) -> str:
246
+ """Require the ``sk_`` plus 26-character ULID format."""
247
+ if not _ID_PATTERN.fullmatch(value):
248
+ raise ValueError("skill_id must use the immutable 'sk_' plus 26-character ULID format")
249
+ return value
250
+
251
+ @field_validator("api_version")
252
+ @classmethod
253
+ def validate_api_version(cls, value: str) -> str:
254
+ """Require schema version ``1.0``."""
255
+ if value != "1.0":
256
+ raise ValueError(f"unsupported Skill schema version: {value!r}")
257
+ return value
258
+
259
+ @field_validator("kind")
260
+ @classmethod
261
+ def validate_kind(cls, value: str) -> str:
262
+ """Require kind ``skill``."""
263
+ if value != "skill":
264
+ raise ValueError("kind must be 'skill'")
265
+ return value
266
+
267
+ @field_validator("instructions")
268
+ @classmethod
269
+ def validate_instructions(cls, value: tuple[str, ...]) -> tuple[str, ...]:
270
+ """Enforce instruction count and size limits and strip whitespace."""
271
+ if (
272
+ not value
273
+ or len(value) > 100
274
+ or any(not item.strip() or len(item) > 12000 for item in value)
275
+ or sum(len(item) for item in value) > 100_000
276
+ ):
277
+ raise ValueError("instructions exceed schema count or size limits")
278
+ return tuple(item.strip() for item in value)
279
+
280
+ @model_validator(mode="after")
281
+ def validate_unique_declarations(self) -> Skill:
282
+ """Reject duplicate declarations and secret-like prompt keys."""
283
+ dep_names = [dependency.name for dependency in self.dependencies]
284
+ if len(dep_names) != len(set(dep_names)):
285
+ raise ValueError("duplicate dependency names are not allowed")
286
+ cap_names = [capability.name for capability in self.capabilities]
287
+ if len(cap_names) != len(set(cap_names)):
288
+ raise ValueError("duplicate required capabilities are not allowed")
289
+ if len(self.provenance.parent_skill_ids) != len(set(self.provenance.parent_skill_ids)):
290
+ raise ValueError("duplicate parent Skill IDs are not allowed")
291
+ if any(_SECRET_KEY_PATTERN.search(key) for key in self.prompts):
292
+ raise ValueError("prompt keys may not represent serialized credentials or secrets")
293
+ return self
294
+
295
+
296
+ class SkillBundle(FrozenModel):
297
+ """Resolved Skill data for an application; this value is not a runtime."""
298
+
299
+ skills: tuple[Skill, ...]
300
+ instructions: tuple[str, ...]
301
+ dependency_order: tuple[str, ...]
302
+ dependencies: tuple[SkillDependency, ...]
303
+ capabilities: dict[str, tuple[Capability, ...]] = Field(default_factory=dict)
304
+ resources: tuple[SkillReference, ...] = ()
305
+ prompts: dict[str, str] = Field(default_factory=dict)
306
+ provenance: tuple[SkillProvenance, ...]
307
+ metadata: dict[str, str] = Field(default_factory=dict)
@@ -0,0 +1,67 @@
1
+ """Provider-neutral observability events for the Skill domain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+ type SkillObservabilityValue = str | int | float | bool
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class SkillObservabilityEvent:
18
+ """One sanitized Skill lifecycle event.
19
+
20
+ Attributes:
21
+ name: Dotted event name, for example ``skill.registered``.
22
+ occurred_at: Timezone-aware time the event happened.
23
+ attributes: Scalar, non-secret event attributes.
24
+ """
25
+
26
+ name: str
27
+ occurred_at: datetime = field(default_factory=lambda: datetime.now(UTC))
28
+ attributes: Mapping[str, SkillObservabilityValue] = field(default_factory=dict)
29
+
30
+
31
+ class SkillObservabilitySink(ABC):
32
+ """Application-owned destination for Skill lifecycle telemetry.
33
+
34
+ `SkillManager` awaits `emit` after each lifecycle operation. A failing sink
35
+ never interrupts the operation; the error is kept on
36
+ `SkillManager.observability_errors`.
37
+
38
+ Example:
39
+ ```python
40
+ class LoggingSink(SkillObservabilitySink):
41
+ async def emit(self, event):
42
+ logger.info(event.name, extra=dict(event.attributes))
43
+ ```
44
+ """
45
+
46
+ @abstractmethod
47
+ async def emit(self, event: SkillObservabilityEvent) -> None:
48
+ """Deliver one event."""
49
+
50
+ async def aclose(self) -> None: # noqa: B027 - optional hook with a no-op default
51
+ """Flush and release resources; the default does nothing."""
52
+
53
+
54
+ class NoOpSkillObservabilitySink(SkillObservabilitySink):
55
+ """Default sink that performs no I/O."""
56
+
57
+ async def emit(self, event: SkillObservabilityEvent) -> None:
58
+ """Discard the event."""
59
+ del event
60
+
61
+
62
+ __all__ = [
63
+ "NoOpSkillObservabilitySink",
64
+ "SkillObservabilityEvent",
65
+ "SkillObservabilitySink",
66
+ "SkillObservabilityValue",
67
+ ]
skill_manager/otel.py ADDED
@@ -0,0 +1,107 @@
1
+ """OpenTelemetry observability sink.
2
+
3
+ Install with ``pip install 'agskillmanager[otel]'``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from datetime import UTC, datetime, timedelta
10
+ from typing import TYPE_CHECKING
11
+
12
+ from skill_manager._optional import optional_import
13
+ from skill_manager.observability import SkillObservabilityEvent, SkillObservabilitySink
14
+
15
+ if TYPE_CHECKING:
16
+ from opentelemetry.metrics import Meter
17
+ from opentelemetry.sdk.metrics import MeterProvider
18
+ from opentelemetry.sdk.trace import TracerProvider
19
+ from opentelemetry.trace import Tracer
20
+
21
+ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
22
+ _MICROSECOND = timedelta(microseconds=1)
23
+
24
+
25
+ def _nanoseconds(moment: datetime) -> int:
26
+ return (moment - _EPOCH) // _MICROSECOND * 1_000
27
+
28
+
29
+ class OpenTelemetrySkillObservabilitySink(SkillObservabilitySink):
30
+ """Emit each Skill event as an OpenTelemetry span and count it as a metric.
31
+
32
+ Each event becomes a zero-duration span named after the event, stamped with
33
+ its time and attributes, and created with ``tracer.start_span`` so the
34
+ current span context never changes; the span's parent is the span current
35
+ at emit time. The ``skill_manager.events`` counter is incremented with an
36
+ ``event.name`` attribute.
37
+
38
+ Pass SDK providers to let the sink flush them on `aclose`; set
39
+ ``owns_providers=True`` to shut them down instead. Without providers or an
40
+ explicit tracer and meter, the global providers are used and never flushed
41
+ or shut down by the sink.
42
+
43
+ Args:
44
+ tracer: Tracer to create spans with.
45
+ meter: Meter to create the event counter with.
46
+ tracer_provider: SDK tracer provider, used when ``tracer`` is omitted.
47
+ meter_provider: SDK meter provider, used when ``meter`` is omitted.
48
+ instrumentation_name: Tracer and meter name.
49
+ owns_providers: Shut the given providers down in `aclose`.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ tracer: Tracer | None = None,
55
+ *,
56
+ meter: Meter | None = None,
57
+ tracer_provider: TracerProvider | None = None,
58
+ meter_provider: MeterProvider | None = None,
59
+ instrumentation_name: str = "skill_manager",
60
+ owns_providers: bool = False,
61
+ ) -> None:
62
+ if tracer is None:
63
+ tracer = (
64
+ tracer_provider.get_tracer(instrumentation_name)
65
+ if tracer_provider is not None
66
+ else optional_import("opentelemetry.trace", "otel").get_tracer(instrumentation_name)
67
+ )
68
+ if meter is None:
69
+ meter = (
70
+ meter_provider.get_meter(instrumentation_name)
71
+ if meter_provider is not None
72
+ else optional_import("opentelemetry.metrics", "otel").get_meter(
73
+ instrumentation_name
74
+ )
75
+ )
76
+ self.tracer: Tracer = tracer
77
+ self.tracer_provider = tracer_provider
78
+ self.meter_provider = meter_provider
79
+ self.owns_providers = owns_providers
80
+ self._counter = meter.create_counter(
81
+ "skill_manager.events", unit="{event}", description="Skill lifecycle events"
82
+ )
83
+
84
+ async def emit(self, event: SkillObservabilityEvent) -> None:
85
+ """Record ``event`` as a span and a counter increment."""
86
+ timestamp = _nanoseconds(event.occurred_at)
87
+ span = self.tracer.start_span(
88
+ event.name, attributes=dict(event.attributes), start_time=timestamp
89
+ )
90
+ span.end(end_time=timestamp)
91
+ self._counter.add(1, {"event.name": event.name})
92
+
93
+ async def aclose(self) -> None:
94
+ """Flush, or shut down when owned, the configured providers."""
95
+ for provider in (self.tracer_provider, self.meter_provider):
96
+ if provider is not None:
97
+ await asyncio.to_thread(_close, provider, shutdown=self.owns_providers)
98
+
99
+
100
+ def _close(provider: TracerProvider | MeterProvider, *, shutdown: bool) -> None:
101
+ if shutdown:
102
+ provider.shutdown()
103
+ else:
104
+ provider.force_flush()
105
+
106
+
107
+ __all__ = ["OpenTelemetrySkillObservabilitySink"]