plm-engine-core 0.1.0a0__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_engine_core/__init__.py +24 -0
- plm_engine_core/agent_runtime/__init__.py +31 -0
- plm_engine_core/agent_runtime/dispatcher.py +343 -0
- plm_engine_core/agent_runtime/selector.py +154 -0
- plm_engine_core/cli/__init__.py +64 -0
- plm_engine_core/cli/__main__.py +15 -0
- plm_engine_core/cli/auth.py +125 -0
- plm_engine_core/control_plane/__init__.py +52 -0
- plm_engine_core/control_plane/_lint_fixtures/__init__.py +16 -0
- plm_engine_core/control_plane/_lint_fixtures/violation_demo.py +28 -0
- plm_engine_core/control_plane/capability_registry/__init__.py +45 -0
- plm_engine_core/control_plane/capability_registry/errors.py +71 -0
- plm_engine_core/control_plane/capability_registry/registry.py +403 -0
- plm_engine_core/control_plane/connectors/__init__.py +53 -0
- plm_engine_core/control_plane/connectors/dispatcher.py +148 -0
- plm_engine_core/control_plane/connectors/runtime.py +214 -0
- plm_engine_core/control_plane/dispatch.py +180 -0
- plm_engine_core/control_plane/hitl/__init__.py +45 -0
- plm_engine_core/control_plane/hitl/errors.py +74 -0
- plm_engine_core/control_plane/hitl/gate.py +555 -0
- plm_engine_core/control_plane/hitl/sql_store.py +380 -0
- plm_engine_core/control_plane/identity/__init__.py +46 -0
- plm_engine_core/control_plane/identity/middleware.py +349 -0
- plm_engine_core/control_plane/identity/provider.py +249 -0
- plm_engine_core/control_plane/identity/system_identity.py +92 -0
- plm_engine_core/control_plane/identity/telemetry.py +146 -0
- plm_engine_core/control_plane/knowledge/__init__.py +79 -0
- plm_engine_core/control_plane/knowledge/curation_index.py +119 -0
- plm_engine_core/control_plane/knowledge/envelope.py +358 -0
- plm_engine_core/control_plane/knowledge/runtime.py +205 -0
- plm_engine_core/control_plane/policies/__init__.py +74 -0
- plm_engine_core/control_plane/policies/authorization.py +192 -0
- plm_engine_core/control_plane/policies/autonomy_gating.py +273 -0
- plm_engine_core/control_plane/policies/base.py +174 -0
- plm_engine_core/control_plane/policies/cost_budget.py +490 -0
- plm_engine_core/control_plane/policies/rate_limit.py +369 -0
- plm_engine_core/control_plane/retry_policy.py +288 -0
- plm_engine_core/control_plane/run_task_tracker.py +262 -0
- plm_engine_core/mcp/__init__.py +63 -0
- plm_engine_core/mcp/asgi.py +195 -0
- plm_engine_core/mcp/errors.py +41 -0
- plm_engine_core/mcp/manifest.py +170 -0
- plm_engine_core/mcp/openapi.py +605 -0
- plm_engine_core/mcp/server.py +219 -0
- plm_engine_core/mcp/tools.py +544 -0
- plm_engine_core/studio/__init__.py +11 -0
- plm_engine_core/studio/readers/__init__.py +25 -0
- plm_engine_core/studio/readers/mcrc_v1.py +221 -0
- plm_engine_core-0.1.0a0.dist-info/METADATA +179 -0
- plm_engine_core-0.1.0a0.dist-info/RECORD +53 -0
- plm_engine_core-0.1.0a0.dist-info/WHEEL +5 -0
- plm_engine_core-0.1.0a0.dist-info/entry_points.txt +2 -0
- plm_engine_core-0.1.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""plm-engine-core — TracePulse PLM Engine Core package (US-CR.1 scaffold).
|
|
2
|
+
|
|
3
|
+
Internally split into two sub-packages aligned with target architecture
|
|
4
|
+
invariant #5 ("execution separated from expertise") and decision
|
|
5
|
+
D-LOCKED-13:
|
|
6
|
+
|
|
7
|
+
* `control_plane/` — governance, autonomy, HITL, run lifecycle,
|
|
8
|
+
trace propagation. Owns *what is allowed*.
|
|
9
|
+
* `agent_runtime/` — selector, dispatcher, retry, escalation.
|
|
10
|
+
Owns *how skills execute*.
|
|
11
|
+
|
|
12
|
+
One-way import contract: `control_plane` MUST NOT import from
|
|
13
|
+
`agent_runtime`. Mechanical enforcement (import-linter rule) lands
|
|
14
|
+
in US-CR.2; until then the rule is documented in README and observed
|
|
15
|
+
by convention.
|
|
16
|
+
|
|
17
|
+
US-CR.1 ships this scaffold deliberately empty — every other US-CR.*
|
|
18
|
+
story (CR.0 identity middleware, CR.2 boundary lint, CR.3-6 policies,
|
|
19
|
+
CR.7 MCP server, CR.8 OpenAPI, CR.9 federated manifest, CR.10 selector,
|
|
20
|
+
CR.11 retry, CR.12 capability registry, CR.13 HITL) lands its
|
|
21
|
+
implementation inside these clear, enforceable internal boundaries
|
|
22
|
+
from day one.
|
|
23
|
+
"""
|
|
24
|
+
__version__ = "0.1.0-alpha"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""plm_engine_core.agent_runtime — selector + dispatcher + retry +
|
|
2
|
+
escalation (US-CR.1 scaffold).
|
|
3
|
+
|
|
4
|
+
Owns *how skills execute*: skill selection, Kernel dispatch, retry
|
|
5
|
+
orchestration on transient failures, escalation when retries
|
|
6
|
+
exhaust. Downstream stories land here:
|
|
7
|
+
|
|
8
|
+
* US-CR.10 — skill selector → Kernel dispatch loopback (runtime side).
|
|
9
|
+
* US-CR.11 — externalised RetryPolicy module.
|
|
10
|
+
|
|
11
|
+
`agent_runtime` may import from `plm_shared.*` (frozen contracts) and
|
|
12
|
+
from `plm_engine_core.control_plane` is **forbidden** by the one-way
|
|
13
|
+
import rule. Decisions about what is allowed live in `control_plane`;
|
|
14
|
+
the runtime asks control_plane for verdicts but does not depend on
|
|
15
|
+
its internals.
|
|
16
|
+
|
|
17
|
+
The `__all__` placeholders below name the public surface downstream
|
|
18
|
+
stories will populate. Importing any of them today raises
|
|
19
|
+
`ImportError` because the modules don't exist yet — placeholder
|
|
20
|
+
list is the contract, not a runtime shim.
|
|
21
|
+
"""
|
|
22
|
+
__all__ = [
|
|
23
|
+
# US-CR.10 — skill selection from a tenant + capability scope.
|
|
24
|
+
"selector",
|
|
25
|
+
# Kernel dispatch loop (runtime side of US-CR.10).
|
|
26
|
+
"dispatcher",
|
|
27
|
+
# US-CR.11 — externalised retry policy (taxonomy-aware).
|
|
28
|
+
"retry",
|
|
29
|
+
# Escalation when retries exhaust or autonomy gate denies.
|
|
30
|
+
"escalation",
|
|
31
|
+
]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""US-CR.10 — KernelDispatcher Protocol + V1 in-process adapter.
|
|
2
|
+
|
|
3
|
+
Decision #51 (Conv M / Q-M2=(c) "Both"): dispatcher lives here and
|
|
4
|
+
``mcp/tools.py::_impl_skill_invoke`` is a thin wrapper around it.
|
|
5
|
+
|
|
6
|
+
**V1 transport scope.** The CR.10 §7.bis frozen wire contract names
|
|
7
|
+
HTTP loopback at ``POST /v1/skills/{id}/invoke`` against a Wave 2
|
|
8
|
+
Kernel stub. Wave 2 Conv A (D-CONV-M-4 closure) wires the actual
|
|
9
|
+
HTTP round-trip:
|
|
10
|
+
|
|
11
|
+
* :class:`KernelDispatcher` — the Protocol surface. Accepts an
|
|
12
|
+
invocation request + dispatch context, returns a result or
|
|
13
|
+
raises a typed terminal exception.
|
|
14
|
+
* :class:`InProcessKernelDispatcher` — the V1 adapter. Behaviour:
|
|
15
|
+
|
|
16
|
+
- ``SKILL_KERNEL_LOOPBACK`` off (default) → returns
|
|
17
|
+
``KERNEL_DISPATCH_DISABLED`` (CR.10 §AC#7).
|
|
18
|
+
- ``SKILL_KERNEL_LOOPBACK`` on → posts the dispatch request to the
|
|
19
|
+
plm-skill-kernel server (default ``http://127.0.0.1:8100``) and
|
|
20
|
+
returns the resulting :class:`DispatchResult`. The Kernel URL is
|
|
21
|
+
overridable via ``SKILL_KERNEL_URL``. ``httpx`` is imported
|
|
22
|
+
lazily so the off-path doesn't require the dep on PYTHONPATH.
|
|
23
|
+
|
|
24
|
+
The Protocol surface is unchanged; only the V1.1 transport branch
|
|
25
|
+
moved from a deferred placeholder to a real round-trip.
|
|
26
|
+
|
|
27
|
+
Cancellation propagation (CR.10 §AC#5 + Decision #28 / CR.1b's
|
|
28
|
+
third deferred consumer): the dispatch context carries an optional
|
|
29
|
+
``RunTaskTracker`` + ``run_id``; the dispatcher checks
|
|
30
|
+
``is_cancelled(run_id)`` before contacting the transport AND short-
|
|
31
|
+
circuits the HTTP call when cancellation fires before the request
|
|
32
|
+
is sent.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from typing import Any, Dict, Optional, Protocol
|
|
40
|
+
|
|
41
|
+
from plm_shared.capability_registry import CapabilityVersion
|
|
42
|
+
|
|
43
|
+
from ..control_plane.run_task_tracker import RunTaskTracker
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Default Kernel URL — Q-W2A-3 chose a standalone Kernel process on
|
|
50
|
+
# port 8100; matches plm-skill-kernel/server.py:DEFAULT_PORT.
|
|
51
|
+
_DEFAULT_KERNEL_URL = "http://127.0.0.1:8100"
|
|
52
|
+
_KERNEL_HTTP_TIMEOUT_SECONDS = 30.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Feature flag ─────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _loopback_enabled() -> bool:
|
|
59
|
+
"""Read the ``SKILL_KERNEL_LOOPBACK`` env flag.
|
|
60
|
+
|
|
61
|
+
Default off (CR.10 §AC#7). The flag exists from V1 so the
|
|
62
|
+
dispatcher layer is wire-compatible with the Wave 2 Kernel stub
|
|
63
|
+
swap; Conv M evaluates the flag to decide between
|
|
64
|
+
``KERNEL_DISPATCH_DISABLED`` and the V1.1 stub.
|
|
65
|
+
"""
|
|
66
|
+
return os.environ.get("SKILL_KERNEL_LOOPBACK", "").lower() in {
|
|
67
|
+
"1",
|
|
68
|
+
"true",
|
|
69
|
+
"on",
|
|
70
|
+
"yes",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Public types ─────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class DispatchRequest:
|
|
79
|
+
"""Inputs to the dispatcher.
|
|
80
|
+
|
|
81
|
+
Mirrors :class:`plm_shared.kernel_api.v1.SkillInvocationRequest`
|
|
82
|
+
field names — V1 doesn't yet serialise; V1.1 will.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
skill_id: str
|
|
86
|
+
skill_version: CapabilityVersion
|
|
87
|
+
arguments: Dict[str, Any]
|
|
88
|
+
tenant_id: str
|
|
89
|
+
autonomy_level: str
|
|
90
|
+
idempotency_key: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class DispatchContext:
|
|
95
|
+
"""Per-call context — cancellation + trace.
|
|
96
|
+
|
|
97
|
+
Wired by CR.7's ``_impl_skill_invoke`` from the inbound MCP
|
|
98
|
+
``ToolCallContext``.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
run_id: Optional[str] = None
|
|
102
|
+
run_task_tracker: Optional[RunTaskTracker] = None
|
|
103
|
+
trace_id: Optional[str] = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class DispatchResult:
|
|
108
|
+
"""V1 typed dispatch result.
|
|
109
|
+
|
|
110
|
+
``ok=True`` carries a JSON-serialisable ``payload``. ``ok=False``
|
|
111
|
+
carries the D-AUDIT-7 ``error_code`` so callers can map to
|
|
112
|
+
:class:`ErrorEnvelope` without an extra match.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
ok: bool
|
|
116
|
+
payload: Optional[Dict[str, Any]] = None
|
|
117
|
+
error_code: Optional[str] = None
|
|
118
|
+
error_message: Optional[str] = None
|
|
119
|
+
cancelled: bool = False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Protocol ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class KernelDispatcher(Protocol):
|
|
126
|
+
"""V1 dispatcher surface (CR.10 §7)."""
|
|
127
|
+
|
|
128
|
+
async def dispatch(
|
|
129
|
+
self,
|
|
130
|
+
request: DispatchRequest,
|
|
131
|
+
context: DispatchContext,
|
|
132
|
+
) -> DispatchResult:
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── InProcessKernelDispatcher — V1 adapter ───────────────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class InProcessKernelDispatcher:
|
|
140
|
+
"""V1 adapter.
|
|
141
|
+
|
|
142
|
+
Behaviour:
|
|
143
|
+
|
|
144
|
+
* Cancellation check first (CR.10 §AC#5).
|
|
145
|
+
* If ``SKILL_KERNEL_LOOPBACK`` is off → ``KERNEL_DISPATCH_DISABLED``
|
|
146
|
+
(CR.10 §AC#7).
|
|
147
|
+
* If on → V1 returns a typed ``deferred_to=US-CR.10-V1.1`` payload
|
|
148
|
+
announcing the Wave 2 Kernel stub deferral. The Wave 2 swap
|
|
149
|
+
replaces this branch with the HTTP round-trip; the Protocol
|
|
150
|
+
surface stays unchanged.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
async def dispatch(
|
|
154
|
+
self,
|
|
155
|
+
request: DispatchRequest,
|
|
156
|
+
context: DispatchContext,
|
|
157
|
+
) -> DispatchResult:
|
|
158
|
+
# AC#5 — cancel-on-disconnect propagates through the dispatch.
|
|
159
|
+
if (
|
|
160
|
+
context.run_task_tracker is not None
|
|
161
|
+
and context.run_id is not None
|
|
162
|
+
and context.run_task_tracker.is_cancelled(context.run_id)
|
|
163
|
+
):
|
|
164
|
+
return DispatchResult(
|
|
165
|
+
ok=True,
|
|
166
|
+
payload={
|
|
167
|
+
"deferred": True,
|
|
168
|
+
"reason": "run cancelled before dispatch",
|
|
169
|
+
"deferred_to": "cancellation",
|
|
170
|
+
"skill_id": request.skill_id,
|
|
171
|
+
"skill_version": request.skill_version.version,
|
|
172
|
+
"cancelled": True,
|
|
173
|
+
},
|
|
174
|
+
cancelled=True,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not _loopback_enabled():
|
|
178
|
+
return DispatchResult(
|
|
179
|
+
ok=False,
|
|
180
|
+
error_code="KERNEL_DISPATCH_DISABLED",
|
|
181
|
+
error_message=(
|
|
182
|
+
"SKILL_KERNEL_LOOPBACK is off; dispatch disabled in V1"
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# V1.1 transport (D-CONV-M-4 closure / Wave 2 Conv A item 8):
|
|
187
|
+
# POST to plm-skill-kernel's HTTP server. The deferred branch
|
|
188
|
+
# is GONE — the flag-on path is now a real round-trip.
|
|
189
|
+
return await self._dispatch_http_loopback(request, context)
|
|
190
|
+
|
|
191
|
+
async def _dispatch_http_loopback(
|
|
192
|
+
self,
|
|
193
|
+
request: DispatchRequest,
|
|
194
|
+
context: DispatchContext,
|
|
195
|
+
) -> DispatchResult:
|
|
196
|
+
"""V1.1 HTTP round-trip to plm-skill-kernel.
|
|
197
|
+
|
|
198
|
+
URL: ``${SKILL_KERNEL_URL or 127.0.0.1:8100}/v1/skills/{id}/invoke``.
|
|
199
|
+
|
|
200
|
+
Headers:
|
|
201
|
+
|
|
202
|
+
* ``X-Core-Caller`` — tenant_id is the V1 stand-in until the
|
|
203
|
+
V1.2 caller-UUID surface lands; matches the Conv F shape
|
|
204
|
+
where the middleware accepts a UUID-shaped opaque value.
|
|
205
|
+
* ``Idempotency-Key`` — propagated when the request carries one.
|
|
206
|
+
* ``traceparent`` — propagated from the dispatch context's
|
|
207
|
+
``trace_id`` field (W3C format expected upstream).
|
|
208
|
+
|
|
209
|
+
Errors are mapped to typed :class:`DispatchResult` with a
|
|
210
|
+
D-AUDIT-7 ``error_code`` rather than raised — the dispatcher's
|
|
211
|
+
contract is "always returns a DispatchResult".
|
|
212
|
+
"""
|
|
213
|
+
# Lazy import: the off-path doesn't need httpx, and even on-path
|
|
214
|
+
# tests can stub the import via monkeypatch.
|
|
215
|
+
try:
|
|
216
|
+
import httpx # type: ignore[import-not-found]
|
|
217
|
+
except ImportError:
|
|
218
|
+
return DispatchResult(
|
|
219
|
+
ok=False,
|
|
220
|
+
error_code="KERNEL_DISPATCH_FAILED",
|
|
221
|
+
error_message=(
|
|
222
|
+
"SKILL_KERNEL_LOOPBACK=on but httpx is not installed; "
|
|
223
|
+
"install httpx>=0.27 in the Engine Core environment"
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
kernel_url = os.environ.get("SKILL_KERNEL_URL", _DEFAULT_KERNEL_URL)
|
|
228
|
+
url = f"{kernel_url.rstrip('/')}/v1/skills/{request.skill_id}/invoke"
|
|
229
|
+
body = {
|
|
230
|
+
"payload": dict(request.arguments),
|
|
231
|
+
"version": request.skill_version.version,
|
|
232
|
+
"autonomy_hint": request.autonomy_level,
|
|
233
|
+
}
|
|
234
|
+
headers: Dict[str, str] = {
|
|
235
|
+
# Tenant-as-X-Core-Caller is the V1 stand-in until the
|
|
236
|
+
# V1.2 caller-UUID surface (W2 Conv C cross-package
|
|
237
|
+
# cleanup) lands; tenant_id is already a UUID at the
|
|
238
|
+
# middleware layer, so the receiver can treat it as one.
|
|
239
|
+
"X-Core-Caller": request.tenant_id,
|
|
240
|
+
"X-Tenant-Id": request.tenant_id,
|
|
241
|
+
"Content-Type": "application/json",
|
|
242
|
+
}
|
|
243
|
+
if request.idempotency_key:
|
|
244
|
+
headers["Idempotency-Key"] = request.idempotency_key
|
|
245
|
+
if context.trace_id:
|
|
246
|
+
headers["traceparent"] = context.trace_id
|
|
247
|
+
if context.run_id:
|
|
248
|
+
headers["X-Run-Id"] = context.run_id
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
async with httpx.AsyncClient(
|
|
252
|
+
timeout=_KERNEL_HTTP_TIMEOUT_SECONDS
|
|
253
|
+
) as client:
|
|
254
|
+
response = await client.post(url, json=body, headers=headers)
|
|
255
|
+
except httpx.TimeoutException as exc:
|
|
256
|
+
logger.warning("kernel dispatch timeout url=%s err=%s", url, exc)
|
|
257
|
+
return DispatchResult(
|
|
258
|
+
ok=False,
|
|
259
|
+
error_code="KERNEL_DISPATCH_TIMEOUT",
|
|
260
|
+
error_message=f"kernel dispatch timed out after {_KERNEL_HTTP_TIMEOUT_SECONDS}s",
|
|
261
|
+
)
|
|
262
|
+
except httpx.HTTPError as exc:
|
|
263
|
+
logger.warning("kernel dispatch http error url=%s err=%s", url, exc)
|
|
264
|
+
return DispatchResult(
|
|
265
|
+
ok=False,
|
|
266
|
+
error_code="KERNEL_DISPATCH_FAILED",
|
|
267
|
+
error_message=f"kernel transport error: {exc}",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# AC#5 - re-check cancellation after the round-trip; a long
|
|
271
|
+
# call may have been cancelled while in-flight.
|
|
272
|
+
if (
|
|
273
|
+
context.run_task_tracker is not None
|
|
274
|
+
and context.run_id is not None
|
|
275
|
+
and context.run_task_tracker.is_cancelled(context.run_id)
|
|
276
|
+
):
|
|
277
|
+
return DispatchResult(
|
|
278
|
+
ok=True,
|
|
279
|
+
payload={
|
|
280
|
+
"deferred": True,
|
|
281
|
+
"reason": "run cancelled during dispatch",
|
|
282
|
+
"deferred_to": "cancellation",
|
|
283
|
+
"skill_id": request.skill_id,
|
|
284
|
+
"skill_version": request.skill_version.version,
|
|
285
|
+
"cancelled": True,
|
|
286
|
+
},
|
|
287
|
+
cancelled=True,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
envelope = response.json()
|
|
292
|
+
except ValueError:
|
|
293
|
+
return DispatchResult(
|
|
294
|
+
ok=False,
|
|
295
|
+
error_code="KERNEL_DISPATCH_FAILED",
|
|
296
|
+
error_message=(
|
|
297
|
+
f"kernel returned non-JSON response (status={response.status_code})"
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if response.status_code >= 500:
|
|
302
|
+
return DispatchResult(
|
|
303
|
+
ok=False,
|
|
304
|
+
error_code="KERNEL_DISPATCH_FAILED",
|
|
305
|
+
error_message=(
|
|
306
|
+
f"kernel returned status {response.status_code}"
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
ok = bool(envelope.get("ok"))
|
|
311
|
+
if not ok:
|
|
312
|
+
err = envelope.get("error") or {}
|
|
313
|
+
return DispatchResult(
|
|
314
|
+
ok=False,
|
|
315
|
+
error_code=err.get("error_code", "KERNEL_DISPATCH_FAILED"),
|
|
316
|
+
error_message=err.get(
|
|
317
|
+
"message", "kernel dispatch failed without message"
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
payload = envelope.get("result") or {}
|
|
322
|
+
# Stamp skill identity for downstream consumers + match the
|
|
323
|
+
# placeholder shape Conv M was returning (skill_id /
|
|
324
|
+
# skill_version are the V1 contract on success).
|
|
325
|
+
if isinstance(payload, dict):
|
|
326
|
+
payload.setdefault("skill_id", request.skill_id)
|
|
327
|
+
payload.setdefault("skill_version", request.skill_version.version)
|
|
328
|
+
|
|
329
|
+
cancelled_flag = bool(envelope.get("cancelled") or False)
|
|
330
|
+
return DispatchResult(
|
|
331
|
+
ok=True,
|
|
332
|
+
payload=payload,
|
|
333
|
+
cancelled=cancelled_flag,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
__all__ = [
|
|
338
|
+
"DispatchContext",
|
|
339
|
+
"DispatchRequest",
|
|
340
|
+
"DispatchResult",
|
|
341
|
+
"InProcessKernelDispatcher",
|
|
342
|
+
"KernelDispatcher",
|
|
343
|
+
]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""US-CR.10 — SkillSelector Protocol + RegistrySkillSelector V1 adapter.
|
|
2
|
+
|
|
3
|
+
Decision #51 (Conv M / Q-M2=(c) "Both"): selector lives in
|
|
4
|
+
``agent_runtime/`` per CR.10 §7 + the ``__init__.py`` placeholder
|
|
5
|
+
laid down by CR.1; CR.7's ``_impl_skill_invoke`` is a thin wiring
|
|
6
|
+
delegating into this module.
|
|
7
|
+
|
|
8
|
+
V1 selector reads CR.12's :class:`CapabilityRegistry` to resolve
|
|
9
|
+
``(skill_id, version)`` to a runnable :class:`SkillResolution`.
|
|
10
|
+
Per CR.10 §6 main business rules: deterministic resolution,
|
|
11
|
+
explicit version honoured, gating refusals short-circuit dispatch.
|
|
12
|
+
|
|
13
|
+
LLM-based or heuristic routing is V1.1+ (CR.10 §"Functional out
|
|
14
|
+
of scope"). V1 is registry-driven only.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import List, Optional, Protocol
|
|
20
|
+
|
|
21
|
+
from plm_shared.capability_registry import (
|
|
22
|
+
AutonomyLevel,
|
|
23
|
+
CapabilityVersion,
|
|
24
|
+
GatingDecision,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from ..control_plane.capability_registry import (
|
|
28
|
+
CapabilityNoSelectableVersionError,
|
|
29
|
+
CapabilityNotFoundError,
|
|
30
|
+
CapabilityRegistry,
|
|
31
|
+
CapabilityRetiredError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Public types ──────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SkillResolution:
|
|
40
|
+
"""Selector outcome — the resolved skill version + the gating verdict.
|
|
41
|
+
|
|
42
|
+
``gating.allowed`` is True iff the call may proceed; the dispatcher
|
|
43
|
+
refuses dispatch when False and surfaces the gating reason.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
skill_id: str
|
|
47
|
+
version: CapabilityVersion
|
|
48
|
+
gating: GatingDecision
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class SelectorError:
|
|
53
|
+
"""Typed error from the selector path.
|
|
54
|
+
|
|
55
|
+
Carries a D-AUDIT-7 code so the dispatcher / MCP tool can convert
|
|
56
|
+
it to an :class:`ErrorEnvelope` directly.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
code: str
|
|
60
|
+
message: str
|
|
61
|
+
skill_id: str
|
|
62
|
+
requested_version: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Protocol ──────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SkillSelector(Protocol):
|
|
69
|
+
"""V1 surface (CR.10 §7).
|
|
70
|
+
|
|
71
|
+
The selector resolves an intent + agent context to a single
|
|
72
|
+
runnable skill version. ``select`` returns a typed result OR a
|
|
73
|
+
typed error; bare exceptions never escape.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def select(
|
|
77
|
+
self,
|
|
78
|
+
skill_id: str,
|
|
79
|
+
*,
|
|
80
|
+
skill_version: Optional[str] = None,
|
|
81
|
+
tenant_id: str,
|
|
82
|
+
autonomy_level: AutonomyLevel,
|
|
83
|
+
granted_scopes: Optional[List[str]] = None,
|
|
84
|
+
) -> SkillResolution | SelectorError:
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── RegistrySkillSelector — V1 adapter ───────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RegistrySkillSelector:
|
|
92
|
+
"""V1 selector reading the dynamic Capability Registry."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, registry: CapabilityRegistry) -> None:
|
|
95
|
+
self._registry = registry
|
|
96
|
+
|
|
97
|
+
def select(
|
|
98
|
+
self,
|
|
99
|
+
skill_id: str,
|
|
100
|
+
*,
|
|
101
|
+
skill_version: Optional[str] = None,
|
|
102
|
+
tenant_id: str,
|
|
103
|
+
autonomy_level: AutonomyLevel,
|
|
104
|
+
granted_scopes: Optional[List[str]] = None,
|
|
105
|
+
) -> SkillResolution | SelectorError:
|
|
106
|
+
try:
|
|
107
|
+
ver = self._registry.negotiate_version(
|
|
108
|
+
skill_id,
|
|
109
|
+
requested_version=skill_version,
|
|
110
|
+
tenant_id=tenant_id,
|
|
111
|
+
)
|
|
112
|
+
except CapabilityNotFoundError:
|
|
113
|
+
return SelectorError(
|
|
114
|
+
code="SKILL_NOT_FOUND",
|
|
115
|
+
message=f"no skill registered for id={skill_id!r}",
|
|
116
|
+
skill_id=skill_id,
|
|
117
|
+
requested_version=skill_version,
|
|
118
|
+
)
|
|
119
|
+
except CapabilityRetiredError:
|
|
120
|
+
return SelectorError(
|
|
121
|
+
code="SKILL_VERSION_NOT_FOUND",
|
|
122
|
+
message=(
|
|
123
|
+
f"skill {skill_id!r} version {skill_version!r} is retired"
|
|
124
|
+
),
|
|
125
|
+
skill_id=skill_id,
|
|
126
|
+
requested_version=skill_version,
|
|
127
|
+
)
|
|
128
|
+
except CapabilityNoSelectableVersionError:
|
|
129
|
+
return SelectorError(
|
|
130
|
+
code="CAPABILITY_NO_SELECTABLE_VERSION",
|
|
131
|
+
message=(
|
|
132
|
+
f"no selectable version for skill {skill_id!r}"
|
|
133
|
+
),
|
|
134
|
+
skill_id=skill_id,
|
|
135
|
+
requested_version=skill_version,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
gating = self._registry.is_gated(
|
|
139
|
+
skill_id,
|
|
140
|
+
version=ver.version,
|
|
141
|
+
tenant_id=tenant_id,
|
|
142
|
+
autonomy_level=autonomy_level,
|
|
143
|
+
granted_scopes=granted_scopes,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return SkillResolution(skill_id=skill_id, version=ver, gating=gating)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = [
|
|
150
|
+
"RegistrySkillSelector",
|
|
151
|
+
"SelectorError",
|
|
152
|
+
"SkillResolution",
|
|
153
|
+
"SkillSelector",
|
|
154
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""plm-cli — operator tooling for plm-engine-core (US-CR.0 PR-3 / Conv F).
|
|
2
|
+
|
|
3
|
+
Lives at the engine-core top level alongside the future Core MCP
|
|
4
|
+
server (US-CR.7) and OpenAPI artefact (US-CR.8) — neither
|
|
5
|
+
control_plane nor agent_runtime, per the CR.1 module placement
|
|
6
|
+
convention. CR.2's import-linter Forbidden contracts do not apply
|
|
7
|
+
here (the CLI is metadata, not a control_plane / agent_runtime
|
|
8
|
+
runtime path).
|
|
9
|
+
|
|
10
|
+
V1 commands:
|
|
11
|
+
plm-cli auth issue-token — mint an HS256 dev/staging token
|
|
12
|
+
consumed by IdentityMiddleware
|
|
13
|
+
(round-trips through PR-2's provider).
|
|
14
|
+
|
|
15
|
+
Future stories add subcommands (e.g. `plm-cli mcp serve` for CR.7);
|
|
16
|
+
the dispatcher below is a flat `command subcommand` shape that can
|
|
17
|
+
absorb new top-level commands without restructuring.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
from typing import Optional, Sequence
|
|
24
|
+
|
|
25
|
+
from . import auth
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
"""Argparse tree: `plm-cli {auth} {subcommand} ...`."""
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="plm-cli",
|
|
32
|
+
description=(
|
|
33
|
+
"Operator tooling for plm-engine-core. V1 ships the "
|
|
34
|
+
"`auth` group; future stories add `mcp`, `policies`, etc."
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
38
|
+
|
|
39
|
+
auth_parser = sub.add_parser(
|
|
40
|
+
"auth",
|
|
41
|
+
help="Issue / inspect identity tokens (HS256 dev path).",
|
|
42
|
+
)
|
|
43
|
+
auth.add_subparsers(auth_parser)
|
|
44
|
+
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
49
|
+
"""Entry point used by both the console_script and tests.
|
|
50
|
+
|
|
51
|
+
Tests should pass `argv=[...]` to drive the parser directly.
|
|
52
|
+
Console-script use leaves argv=None; argparse reads sys.argv.
|
|
53
|
+
Returns the process exit code (0 = success, non-zero = error).
|
|
54
|
+
"""
|
|
55
|
+
parser = _build_parser()
|
|
56
|
+
args = parser.parse_args(argv)
|
|
57
|
+
if args.command == "auth":
|
|
58
|
+
return auth.dispatch(args)
|
|
59
|
+
parser.error(f"unknown command: {args.command}")
|
|
60
|
+
return 2 # unreachable; argparse exits
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__": # pragma: no cover
|
|
64
|
+
sys.exit(main())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Module-execution entry: `python -m plm_engine_core.cli ...`.
|
|
2
|
+
|
|
3
|
+
Mirrors the console_script `plm-cli` registered in pyproject.toml,
|
|
4
|
+
useful when the script-shim isn't on PATH (fresh editable install,
|
|
5
|
+
CI containers without entry-point shims).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import main
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
sys.exit(main())
|