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/models.py
ADDED
|
@@ -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"]
|