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.
- plm_skill_kernel/__init__.py +33 -0
- plm_skill_kernel/__main__.py +13 -0
- plm_skill_kernel/dispatcher.py +141 -0
- plm_skill_kernel/registry.py +80 -0
- plm_skill_kernel/sdk/__init__.py +29 -0
- plm_skill_kernel/sdk/_registry.py +111 -0
- plm_skill_kernel/sdk/context.py +66 -0
- plm_skill_kernel/sdk/decorator.py +147 -0
- plm_skill_kernel/sdk/types.py +86 -0
- plm_skill_kernel/server.py +173 -0
- plm_skill_kernel/skills/__init__.py +16 -0
- plm_skill_kernel/skills/agents/__init__.py +13 -0
- plm_skill_kernel/skills/agents/suggest.py +191 -0
- plm_skill_kernel/skills/bpmn/__init__.py +9 -0
- plm_skill_kernel/skills/bpmn/generate.py +147 -0
- plm_skill_kernel/skills/cleansing/__init__.py +10 -0
- plm_skill_kernel/skills/cleansing/dedupe.py +162 -0
- plm_skill_kernel/skills/cleansing/normalise.py +178 -0
- plm_skill_kernel/skills/documents/__init__.py +12 -0
- plm_skill_kernel/skills/documents/parse.py +202 -0
- plm_skill_kernel/skills/knowledge/__init__.py +32 -0
- plm_skill_kernel/skills/knowledge/_v1_corpus.py +153 -0
- plm_skill_kernel/skills/knowledge/get_record.py +102 -0
- plm_skill_kernel/skills/knowledge/resolve.py +143 -0
- plm_skill_kernel/skills/knowledge/retrieve.py +143 -0
- plm_skill_kernel/skills/knowledge/search.py +161 -0
- plm_skill_kernel/skills/knowledge/validate.py +222 -0
- plm_skill_kernel/skills_legacy/__init__.py +0 -0
- plm_skill_kernel/skills_legacy/knowledge_pack_loader.py +17 -0
- plm_skill_kernel/skills_legacy/knowledge_search_engine.py +25 -0
- plm_skill_kernel/skills_legacy/plm_skill_registry.py +573 -0
- plm_skill_kernel/skills_legacy/plm_tool_definitions.py +44 -0
- plm_skill_kernel/skills_legacy/skill_orchestrator.py +163 -0
- plm_skill_kernel-1.0.0.dist-info/METADATA +135 -0
- plm_skill_kernel-1.0.0.dist-info/RECORD +38 -0
- plm_skill_kernel-1.0.0.dist-info/WHEEL +5 -0
- plm_skill_kernel-1.0.0.dist-info/entry_points.txt +2 -0
- 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"]
|