plm-skill-kernel 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.
Files changed (38) hide show
  1. plm_skill_kernel/__init__.py +33 -0
  2. plm_skill_kernel/__main__.py +13 -0
  3. plm_skill_kernel/dispatcher.py +141 -0
  4. plm_skill_kernel/registry.py +80 -0
  5. plm_skill_kernel/sdk/__init__.py +29 -0
  6. plm_skill_kernel/sdk/_registry.py +111 -0
  7. plm_skill_kernel/sdk/context.py +66 -0
  8. plm_skill_kernel/sdk/decorator.py +147 -0
  9. plm_skill_kernel/sdk/types.py +86 -0
  10. plm_skill_kernel/server.py +173 -0
  11. plm_skill_kernel/skills/__init__.py +16 -0
  12. plm_skill_kernel/skills/agents/__init__.py +13 -0
  13. plm_skill_kernel/skills/agents/suggest.py +191 -0
  14. plm_skill_kernel/skills/bpmn/__init__.py +9 -0
  15. plm_skill_kernel/skills/bpmn/generate.py +147 -0
  16. plm_skill_kernel/skills/cleansing/__init__.py +10 -0
  17. plm_skill_kernel/skills/cleansing/dedupe.py +162 -0
  18. plm_skill_kernel/skills/cleansing/normalise.py +178 -0
  19. plm_skill_kernel/skills/documents/__init__.py +12 -0
  20. plm_skill_kernel/skills/documents/parse.py +202 -0
  21. plm_skill_kernel/skills/knowledge/__init__.py +32 -0
  22. plm_skill_kernel/skills/knowledge/_v1_corpus.py +153 -0
  23. plm_skill_kernel/skills/knowledge/get_record.py +102 -0
  24. plm_skill_kernel/skills/knowledge/resolve.py +143 -0
  25. plm_skill_kernel/skills/knowledge/retrieve.py +143 -0
  26. plm_skill_kernel/skills/knowledge/search.py +161 -0
  27. plm_skill_kernel/skills/knowledge/validate.py +222 -0
  28. plm_skill_kernel/skills_legacy/__init__.py +0 -0
  29. plm_skill_kernel/skills_legacy/knowledge_pack_loader.py +17 -0
  30. plm_skill_kernel/skills_legacy/knowledge_search_engine.py +25 -0
  31. plm_skill_kernel/skills_legacy/plm_skill_registry.py +573 -0
  32. plm_skill_kernel/skills_legacy/plm_tool_definitions.py +44 -0
  33. plm_skill_kernel/skills_legacy/skill_orchestrator.py +163 -0
  34. plm_skill_kernel-1.0.0.dist-info/METADATA +135 -0
  35. plm_skill_kernel-1.0.0.dist-info/RECORD +38 -0
  36. plm_skill_kernel-1.0.0.dist-info/WHEEL +5 -0
  37. plm_skill_kernel-1.0.0.dist-info/entry_points.txt +2 -0
  38. plm_skill_kernel-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,33 @@
1
+ """TracePulse PLM Skill Kernel — Wave 2 Conv A (FTR-604).
2
+
3
+ Sibling of ``plm-engine-core``. Owns:
4
+
5
+ * The decorator-based Skill SDK (Decision #59 — Q-W2A-1 nested layout).
6
+ * The V1 in-process dispatcher.
7
+ * The Kernel HTTP server stub at ``POST /v1/skills/{id}/invoke``
8
+ matching CR.10 §7.bis verbatim.
9
+ * 3 starter skills harvested per Decision #60 — ``cleansing.normalise``,
10
+ ``cleansing.dedupe``, ``bpmn.generate``.
11
+
12
+ The Kernel and the Engine Core are deployed-separable. Their only
13
+ sanctioned interaction is the V1.1 HTTP loopback (env-flagged on
14
+ ``SKILL_KERNEL_LOOPBACK``); any Python-level reach-in is forbidden by
15
+ the import-linter contract in ``pyproject.toml``.
16
+ """
17
+ __version__ = "0.1.0-alpha"
18
+
19
+ # The decorator + sdk surface is the public authoring API. Re-exported
20
+ # here so callers write ``from plm_skill_kernel import skill`` rather
21
+ # than the deeper path. Skill author imports stay short + stable.
22
+ from .sdk.decorator import skill
23
+ from .sdk.context import SkillContext
24
+ from .sdk.types import SkillInvokeRequest, SkillInvokeResult, SkillErrorEnvelope
25
+
26
+ __all__ = [
27
+ "SkillContext",
28
+ "SkillErrorEnvelope",
29
+ "SkillInvokeRequest",
30
+ "SkillInvokeResult",
31
+ "__version__",
32
+ "skill",
33
+ ]
@@ -0,0 +1,13 @@
1
+ """``python -m plm_skill_kernel`` entry — delegates to :func:`server._cli_entry`.
2
+
3
+ Q-W2A-3 picked the standalone launch shape. The ``__main__`` module is
4
+ the canonical Python idiom; the ``plm-skill-kernel`` console script
5
+ points at the same function via ``pyproject.toml [project.scripts]``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .server import _cli_entry
10
+
11
+
12
+ if __name__ == "__main__":
13
+ _cli_entry()
@@ -0,0 +1,141 @@
1
+ """V1 in-process Kernel dispatcher.
2
+
3
+ Resolves ``(skill_id, version)`` against the SDK registry, validates the
4
+ payload + context, and invokes the registered callable. Exceptions are
5
+ caught + converted to a typed :class:`SkillInvokeResult` carrying a
6
+ :class:`SkillErrorEnvelope` — bare exceptions never escape the dispatch
7
+ boundary so the HTTP server can rely on always getting back a
8
+ serialisable result.
9
+
10
+ Decision #51 (Conv M / Q-M2 = (c) "Both"): the dispatcher lives in
11
+ plm-skill-kernel; the plm-engine-core dispatcher (under
12
+ ``plm_engine_core.agent_runtime.dispatcher``) becomes a thin transport
13
+ proxy when ``SKILL_KERNEL_LOOPBACK=on`` (Wave 2 Conv A item 8 — the
14
+ V1.1 transport closure for D-CONV-M-4).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Any, Dict, Optional
20
+
21
+ from .sdk._registry import RegisteredSkill, _Registry, get_registry
22
+ from .sdk.context import SkillContext
23
+ from .sdk.types import (
24
+ SkillErrorEnvelope,
25
+ SkillInvokeRequest,
26
+ SkillInvokeResult,
27
+ )
28
+
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # ── Error codes (D-AUDIT-7 entries owned by the Skill Kernel) ────────────
34
+ #
35
+ # These mirror string-values declared on
36
+ # ``plm_engine_core.control_plane.capability_registry.errors`` +
37
+ # ``services/llm_errors.py``. Wave 2 Conv A re-uses the existing
38
+ # ``SKILL_NOT_FOUND`` / ``SKILL_VERSION_NOT_FOUND`` /
39
+ # ``KERNEL_DISPATCH_DISABLED`` strings (no new codes — the close
40
+ # audit at Conv N tallied 57 codes; this conv keeps that total).
41
+ SKILL_NOT_FOUND = "SKILL_NOT_FOUND"
42
+ SKILL_VERSION_NOT_FOUND = "SKILL_VERSION_NOT_FOUND"
43
+ KERNEL_DISPATCH_FAILED = "KERNEL_DISPATCH_FAILED"
44
+
45
+
46
+ class InProcessDispatcher:
47
+ """V1 in-process invoker.
48
+
49
+ Constructor accepts an explicit registry so tests can isolate
50
+ registration state. Production callers receive the module-level
51
+ singleton via :func:`get_registry`.
52
+ """
53
+
54
+ def __init__(self, registry: Optional[_Registry] = None) -> None:
55
+ self._registry = registry if registry is not None else get_registry()
56
+
57
+ async def dispatch(
58
+ self,
59
+ skill_id: str,
60
+ request: SkillInvokeRequest,
61
+ *,
62
+ context: SkillContext,
63
+ ) -> SkillInvokeResult:
64
+ """Resolve + invoke a skill.
65
+
66
+ Returns a :class:`SkillInvokeResult` always — exceptions raised
67
+ by the skill body are caught and surfaced via the
68
+ ``KERNEL_DISPATCH_FAILED`` envelope.
69
+ """
70
+ entry = self._registry.resolve(skill_id, request.version)
71
+ if entry is None:
72
+ return self._not_found(skill_id, request.version)
73
+
74
+ try:
75
+ result = await entry.fn(dict(request.payload), context)
76
+ except Exception as exc: # noqa: BLE001 — boundary; we re-package
77
+ logger.exception(
78
+ "skill dispatch failed skill_id=%s version=%s",
79
+ skill_id,
80
+ request.version,
81
+ )
82
+ return SkillInvokeResult(
83
+ ok=False,
84
+ error=SkillErrorEnvelope(
85
+ error_code=KERNEL_DISPATCH_FAILED,
86
+ message=f"skill {skill_id!r} raised {type(exc).__name__}",
87
+ detail={"exception_message": str(exc)},
88
+ ),
89
+ )
90
+
91
+ # V1 contract: skills return a JSON-serialisable dict.
92
+ if not isinstance(result, dict):
93
+ return SkillInvokeResult(
94
+ ok=False,
95
+ error=SkillErrorEnvelope(
96
+ error_code=KERNEL_DISPATCH_FAILED,
97
+ message=(
98
+ f"skill {skill_id!r} returned a non-dict result; "
99
+ "V1 contract requires `dict[str, Any]`"
100
+ ),
101
+ detail={"actual_type": type(result).__name__},
102
+ ),
103
+ )
104
+
105
+ return SkillInvokeResult(ok=True, result=result)
106
+
107
+ def _not_found(self, skill_id: str, version: str) -> SkillInvokeResult:
108
+ # Differentiate "skill_id has no entries" from
109
+ # "skill_id exists but the requested version is missing" by
110
+ # walking the registry once.
111
+ any_version_for_id = any(
112
+ e.skill_id == skill_id for e in self._registry.list_all()
113
+ )
114
+ if any_version_for_id:
115
+ return SkillInvokeResult(
116
+ ok=False,
117
+ error=SkillErrorEnvelope(
118
+ error_code=SKILL_VERSION_NOT_FOUND,
119
+ message=(
120
+ f"skill {skill_id!r} version {version!r} is not "
121
+ "registered"
122
+ ),
123
+ detail={"skill_id": skill_id, "version": version},
124
+ ),
125
+ )
126
+ return SkillInvokeResult(
127
+ ok=False,
128
+ error=SkillErrorEnvelope(
129
+ error_code=SKILL_NOT_FOUND,
130
+ message=f"no skill registered for id={skill_id!r}",
131
+ detail={"skill_id": skill_id, "version": version},
132
+ ),
133
+ )
134
+
135
+
136
+ __all__ = [
137
+ "InProcessDispatcher",
138
+ "KERNEL_DISPATCH_FAILED",
139
+ "SKILL_NOT_FOUND",
140
+ "SKILL_VERSION_NOT_FOUND",
141
+ ]
@@ -0,0 +1,80 @@
1
+ """Boot-time skill discovery + registration.
2
+
3
+ Importing :func:`load_starter_skills` triggers the import of each
4
+ starter-skill module. Each module's top-level ``@skill(...)``
5
+ decorator runs as a side-effect of import, so by the time
6
+ :func:`load_starter_skills` returns the SDK registry contains every
7
+ starter skill.
8
+
9
+ This module is the V1 wiring point — production ``server.py`` calls
10
+ :func:`load_starter_skills` at startup. Tests that need a clean
11
+ registry call ``get_registry().clear()`` first.
12
+
13
+ Wave 3 Conv A (per Decision #60) added ``documents.parse`` +
14
+ ``agents.suggest`` to the fan-out — bringing the starter set to 5
15
+ skills (3 Wave 2 + 2 Wave 3).
16
+
17
+ Wave 5 Conv C (FTR-603 / Decision #122 + #124) adds the 5 active V1
18
+ ``knowledge.*`` primitives — bringing the boot-loaded set to 10
19
+ skills (3 Wave 2 + 2 Wave 3 + 5 Wave 5). The 2 deferred primitives
20
+ (``explain`` / ``context_pack``) stay YAML ``draft`` capabilities
21
+ only and do NOT register a kernel skill in V1; their dispatch is
22
+ short-circuited by the engine-core envelope returning
23
+ ``KS_PRIMITIVE_DEFERRED_V1_1``.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from typing import List, Tuple
29
+
30
+ from .sdk._registry import RegisteredSkill, get_registry
31
+
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def load_starter_skills() -> List[RegisteredSkill]:
37
+ """Import the 5 starter skills + return the resulting list.
38
+
39
+ The imports themselves run the decorator side-effects; this
40
+ function returns the registry's view post-load so callers can
41
+ log or assert on the exact skills available.
42
+
43
+ Idempotent — re-importing a module is a no-op (Python caches),
44
+ and the SDK registry tolerates same-callable re-registration.
45
+ """
46
+ # Local imports — module-load side effect is the registration. The
47
+ # imports MUST be inside the function so test fixtures that clear
48
+ # the registry can re-trigger registration by re-calling.
49
+ # NOTE: `noqa: F401` because the bound names are unused; the
50
+ # imports' side effect (decorator registration) is the point.
51
+ from .skills.cleansing import dedupe as _dedupe # noqa: F401
52
+ from .skills.cleansing import normalise as _normalise # noqa: F401
53
+ from .skills.bpmn import generate as _generate # noqa: F401
54
+ from .skills.documents import parse as _parse # noqa: F401
55
+ from .skills.agents import suggest as _suggest # noqa: F401
56
+ # Wave 5 Conv C — FTR-603 Knowledge Services V1 active primitives.
57
+ from .skills.knowledge import search as _ks_search # noqa: F401
58
+ from .skills.knowledge import get_record as _ks_get_record # noqa: F401
59
+ from .skills.knowledge import retrieve as _ks_retrieve # noqa: F401
60
+ from .skills.knowledge import resolve as _ks_resolve # noqa: F401
61
+ from .skills.knowledge import validate as _ks_validate # noqa: F401
62
+
63
+ entries = get_registry().list_all()
64
+ logger.info(
65
+ "skill_kernel boot_loaded count=%s ids=%s",
66
+ len(entries),
67
+ ",".join(sorted(f"{e.skill_id}@{e.version}" for e in entries)),
68
+ )
69
+ return entries
70
+
71
+
72
+ def list_registered_skill_ids() -> List[Tuple[str, str]]:
73
+ """Return ``(skill_id, version)`` pairs for every registered entry.
74
+
75
+ Used by the federation manifest builder + introspection endpoints.
76
+ """
77
+ return [(e.skill_id, e.version) for e in get_registry().list_all()]
78
+
79
+
80
+ __all__ = ["list_registered_skill_ids", "load_starter_skills"]
@@ -0,0 +1,29 @@
1
+ """Public Skill SDK surface (Wave 2 Conv A / Decision #59).
2
+
3
+ Skill authors import from here:
4
+
5
+ from plm_skill_kernel import skill, SkillContext
6
+ # or, equivalently:
7
+ from plm_skill_kernel.sdk import skill, SkillContext
8
+
9
+ The decorator + context + wire types are the V1-stable authoring
10
+ surface; internal helpers (the registry implementation, validation
11
+ helpers) live under ``plm_skill_kernel.sdk._registry`` and are NOT
12
+ re-exported.
13
+ """
14
+ from .context import SkillContext
15
+ from .decorator import SkillDecoratorError, skill
16
+ from .types import (
17
+ SkillErrorEnvelope,
18
+ SkillInvokeRequest,
19
+ SkillInvokeResult,
20
+ )
21
+
22
+ __all__ = [
23
+ "SkillContext",
24
+ "SkillDecoratorError",
25
+ "SkillErrorEnvelope",
26
+ "SkillInvokeRequest",
27
+ "SkillInvokeResult",
28
+ "skill",
29
+ ]
@@ -0,0 +1,111 @@
1
+ """Internal in-process decorator registry.
2
+
3
+ The :func:`plm_skill_kernel.sdk.skill` decorator stamps each
4
+ ``async def`` it wraps into this module-level registry at import
5
+ time. The Kernel dispatcher then resolves ``(skill_id, version)``
6
+ back to the callable at dispatch time.
7
+
8
+ Hot-reload (V1.1) replaces a single entry atomically; V1 callers that
9
+ re-import a skill module will get a duplicate-registration error so
10
+ test suites can detect accidental re-registration.
11
+
12
+ Decision #25 / #41 / #52 / #53 V1 in-memory + Protocol pattern:
13
+ the registry is a module-level dict + an ``RLock``. Wave 4+ may swap
14
+ the backing store for a process-shared cache without changing the
15
+ public surface (which is just ``register`` + ``resolve`` + ``list_all``).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ from dataclasses import dataclass
21
+ from typing import Awaitable, Callable, Dict, List, Optional, Tuple
22
+
23
+ from .context import SkillContext
24
+
25
+
26
+ # Type alias for a registered skill body — every skill is
27
+ # ``async def fn(payload: dict, ctx: SkillContext) -> dict``.
28
+ SkillCallable = Callable[
29
+ [Dict, SkillContext], Awaitable[Dict]
30
+ ]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RegisteredSkill:
35
+ """One entry in the decorator registry.
36
+
37
+ The ``min_autonomy`` + ``streaming`` + ``hitl_required`` fields
38
+ are advisory metadata the SDK can pass through to the federation
39
+ manifest builder (CR.9) at boot. They are NOT enforced at dispatch
40
+ time — Core's selector + gating already ran upstream.
41
+ """
42
+
43
+ skill_id: str
44
+ version: str
45
+ fn: SkillCallable
46
+ description: Optional[str]
47
+ min_autonomy: str # "L1" / "L2" / "L3"
48
+ streaming: bool
49
+ hitl_required: bool
50
+
51
+
52
+ class _Registry:
53
+ """Thread-safe registry of decorated skills.
54
+
55
+ Keyed by ``(skill_id, version)``. Adding a duplicate raises a
56
+ :class:`RuntimeError` so test fixtures catch double-registration
57
+ rather than silently shadowing.
58
+ """
59
+
60
+ def __init__(self) -> None:
61
+ self._lock = threading.RLock()
62
+ self._entries: Dict[Tuple[str, str], RegisteredSkill] = {}
63
+
64
+ def register(self, entry: RegisteredSkill) -> None:
65
+ key = (entry.skill_id, entry.version)
66
+ with self._lock:
67
+ existing = self._entries.get(key)
68
+ if existing is not None and existing.fn is not entry.fn:
69
+ raise RuntimeError(
70
+ f"skill {entry.skill_id!r} version {entry.version!r} "
71
+ "is already registered with a different callable; "
72
+ "duplicate registration is forbidden in V1"
73
+ )
74
+ self._entries[key] = entry
75
+
76
+ def resolve(
77
+ self, skill_id: str, version: str
78
+ ) -> Optional[RegisteredSkill]:
79
+ with self._lock:
80
+ return self._entries.get((skill_id, version))
81
+
82
+ def list_all(self) -> List[RegisteredSkill]:
83
+ with self._lock:
84
+ return list(self._entries.values())
85
+
86
+ def clear(self) -> None:
87
+ """Test-only — unregister everything. Production callers MUST NOT."""
88
+ with self._lock:
89
+ self._entries.clear()
90
+
91
+
92
+ # Module-level singleton. Imported by the decorator + the dispatcher.
93
+ _REGISTRY = _Registry()
94
+
95
+
96
+ def get_registry() -> _Registry:
97
+ """Return the module-level registry singleton.
98
+
99
+ Tests that need isolation can call ``get_registry().clear()`` in a
100
+ fixture's setup; production callers never construct a fresh registry
101
+ (skills register at import time into the singleton).
102
+ """
103
+ return _REGISTRY
104
+
105
+
106
+ __all__ = [
107
+ "RegisteredSkill",
108
+ "SkillCallable",
109
+ "_Registry",
110
+ "get_registry",
111
+ ]
@@ -0,0 +1,66 @@
1
+ """V1 :class:`SkillContext` — per-call ambient context handed to skills.
2
+
3
+ Decision #59 (Conv N / Q-N4 = Option α decorator) — the SDK takes the
4
+ shape ``async def skill(payload, ctx) -> dict`` so every skill function
5
+ receives a typed context object alongside the validated payload.
6
+
7
+ The context carries the bits a skill genuinely needs in V1:
8
+
9
+ * ``tenant_id`` — for tenant-scoped reads / writes (RLS still
10
+ applies at the DB layer if the skill performs persistence).
11
+ * ``run_id`` — links Kernel-side telemetry back to the originating
12
+ Core run (CR.1b RunTaskTracker surface).
13
+ * ``trace_id`` — W3C ``traceparent`` propagation; emitted alongside
14
+ any telemetry the skill itself produces (V1.1 — V1 skills do NOT
15
+ emit telemetry directly; the Kernel's dispatcher records the
16
+ Skill-level invocation envelope).
17
+ * ``autonomy_level`` — copied from ``SkillInvokeRequest.autonomy_hint``;
18
+ advisory, NOT authoritative.
19
+ * ``idempotency_key`` — propagated from CR.10's caller; skills MAY
20
+ use it to dedupe side-effects.
21
+
22
+ Skills MUST treat the context as immutable — the Pydantic model is
23
+ ``frozen=True``.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from typing import Optional
28
+
29
+ from pydantic import BaseModel, ConfigDict, Field
30
+
31
+
32
+ class SkillContext(BaseModel):
33
+ """Per-call context object handed to every ``@skill``-decorated function.
34
+
35
+ Construct via :meth:`SkillContext.from_request` from the inbound
36
+ HTTP request + parsed body — server.py owns the construction so
37
+ skills never see the raw HTTP layer.
38
+ """
39
+
40
+ model_config = ConfigDict(frozen=True, extra="forbid")
41
+
42
+ skill_id: str = Field(min_length=1)
43
+ skill_version: str = Field(min_length=1)
44
+ tenant_id: str = Field(default="anonymous", min_length=1)
45
+ run_id: Optional[str] = None
46
+ trace_id: Optional[str] = None
47
+ autonomy_level: Optional[str] = Field(
48
+ default=None,
49
+ description=(
50
+ "Advisory autonomy level (L1/L2/L3). Skills MAY consult "
51
+ "this for behaviour gating but MUST NOT treat it as "
52
+ "authoritative — Core already gated."
53
+ ),
54
+ )
55
+ idempotency_key: Optional[str] = None
56
+ core_caller: Optional[str] = Field(
57
+ default=None,
58
+ description=(
59
+ "X-Core-Caller header value (UUID). Identifies the Core "
60
+ "instance that issued the dispatch. None when the skill "
61
+ "is invoked directly (dev / test paths)."
62
+ ),
63
+ )
64
+
65
+
66
+ __all__ = ["SkillContext"]
@@ -0,0 +1,147 @@
1
+ """``@skill`` decorator — Decision #59 (Conv N / Q-N4 = Option α).
2
+
3
+ Skill authoring shape:
4
+
5
+ from plm_skill_kernel import skill, SkillContext
6
+
7
+ @skill(id="cleansing.normalise", version="1.0.0", min_autonomy="L2")
8
+ async def normalise(payload: dict, ctx: SkillContext) -> dict:
9
+ ...
10
+
11
+ The decorator validates the function's shape (must be ``async def`` +
12
+ 2 positional params) and registers it into the module-level registry
13
+ at import time. ``@skill`` does NOT mutate the function — calling the
14
+ decorated callable still works as a plain async function (so unit
15
+ tests can call it without going through the Kernel).
16
+
17
+ Validation:
18
+
19
+ * ``id`` — non-empty dotted string (``a.b`` / ``a.b.c``).
20
+ * ``version`` — non-empty string; opaque to the registry (the
21
+ registry sorts by lifecycle precedence, not semver).
22
+ * ``min_autonomy`` — must be ``"L1"`` / ``"L2"`` / ``"L3"``.
23
+
24
+ The decorator raises :class:`SkillDecoratorError` (a ``ValueError``
25
+ subclass) on any validation failure, fail-fast at import time.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import inspect
30
+ import re
31
+ from typing import Awaitable, Callable, Dict, Optional
32
+
33
+ from ._registry import RegisteredSkill, SkillCallable, get_registry
34
+ from .context import SkillContext
35
+
36
+
37
+ _VALID_AUTONOMY = frozenset({"L1", "L2", "L3"})
38
+ _SKILL_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
39
+
40
+
41
+ class SkillDecoratorError(ValueError):
42
+ """Raised when ``@skill(...)`` arguments or the wrapped function
43
+ fail validation. Surfaces at module import time.
44
+ """
45
+
46
+
47
+ def _validate_skill_id(skill_id: str) -> None:
48
+ if not isinstance(skill_id, str) or not skill_id:
49
+ raise SkillDecoratorError("@skill: id must be a non-empty string")
50
+ if not _SKILL_ID_PATTERN.match(skill_id):
51
+ raise SkillDecoratorError(
52
+ f"@skill: id must be dotted lowercase identifiers "
53
+ f"(e.g. 'cleansing.normalise'); got {skill_id!r}"
54
+ )
55
+
56
+
57
+ def _validate_version(version: str) -> None:
58
+ if not isinstance(version, str) or not version.strip():
59
+ raise SkillDecoratorError("@skill: version must be a non-empty string")
60
+
61
+
62
+ def _validate_autonomy(level: str) -> None:
63
+ if level not in _VALID_AUTONOMY:
64
+ raise SkillDecoratorError(
65
+ f"@skill: min_autonomy must be one of L1/L2/L3; got {level!r}"
66
+ )
67
+
68
+
69
+ def _validate_callable(fn: Callable) -> None:
70
+ if not inspect.iscoroutinefunction(fn):
71
+ raise SkillDecoratorError(
72
+ f"@skill: {fn.__qualname__} must be `async def` "
73
+ "(V1 dispatch is fully async)"
74
+ )
75
+ sig = inspect.signature(fn)
76
+ pos_params = [
77
+ p
78
+ for p in sig.parameters.values()
79
+ if p.kind
80
+ in (
81
+ inspect.Parameter.POSITIONAL_ONLY,
82
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
83
+ )
84
+ ]
85
+ if len(pos_params) != 2:
86
+ raise SkillDecoratorError(
87
+ f"@skill: {fn.__qualname__} must accept exactly 2 positional "
88
+ "params (payload: dict, ctx: SkillContext); "
89
+ f"got {len(pos_params)}"
90
+ )
91
+
92
+
93
+ def skill(
94
+ *,
95
+ id: str,
96
+ version: str,
97
+ description: Optional[str] = None,
98
+ min_autonomy: str = "L1",
99
+ streaming: bool = False,
100
+ hitl_required: bool = False,
101
+ ) -> Callable[[SkillCallable], SkillCallable]:
102
+ """Register an ``async def`` as a Skill.
103
+
104
+ Returns the original callable unchanged — the decorator's only
105
+ side-effect is registry insertion, so unit tests can ``await fn(...)``
106
+ without going through the Kernel.
107
+
108
+ Args:
109
+ id: dotted skill identifier (``cleansing.normalise``).
110
+ version: opaque version string (``1.0.0``).
111
+ description: optional human-readable summary; surfaced on
112
+ ``GET /v1/skills`` introspection + the federation manifest.
113
+ min_autonomy: ``"L1"`` / ``"L2"`` / ``"L3"`` — advisory metadata.
114
+ Default ``"L1"``.
115
+ streaming: True for skills that emit progress chunks (V1.1
116
+ wire-protocol — V1 ignores the bit beyond manifest reporting).
117
+ hitl_required: True for skills whose work needs an explicit
118
+ HITL approval before completion. Surfaced on the manifest
119
+ so callers can pre-warn UI; the actual HITL ticket
120
+ lifecycle is owned by CR.13's HitlGate, not the Kernel.
121
+
122
+ Raises:
123
+ SkillDecoratorError: any validation failure (bad id, bad version,
124
+ wrong function shape, duplicate registration).
125
+ """
126
+ _validate_skill_id(id)
127
+ _validate_version(version)
128
+ _validate_autonomy(min_autonomy)
129
+
130
+ def _wrap(fn: SkillCallable) -> SkillCallable:
131
+ _validate_callable(fn)
132
+ entry = RegisteredSkill(
133
+ skill_id=id,
134
+ version=version,
135
+ fn=fn,
136
+ description=description,
137
+ min_autonomy=min_autonomy,
138
+ streaming=streaming,
139
+ hitl_required=hitl_required,
140
+ )
141
+ get_registry().register(entry)
142
+ return fn
143
+
144
+ return _wrap
145
+
146
+
147
+ __all__ = ["SkillDecoratorError", "skill"]