plm-skill-kernel 1.1.0__tar.gz → 1.2.0__tar.gz
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-1.1.0 → plm_skill_kernel-1.2.0}/PKG-INFO +1 -1
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/__init__.py +1 -1
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/__init__.py +47 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/_catalog_fixture.py +80 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/binding.py +64 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/executor.py +173 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/knowledge.py +64 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/plan.py +110 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/request.py +79 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/executor/selector.py +59 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/mocks/__init__.py +53 -0
- plm_skill_kernel-1.2.0/plm_skill_kernel/mocks/foundation_seed.py +207 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/PKG-INFO +1 -1
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/SOURCES.txt +12 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/pyproject.toml +1 -1
- plm_skill_kernel-1.2.0/tests/test_executor.py +308 -0
- plm_skill_kernel-1.2.0/tests/test_foundation_mocks.py +138 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/README.md +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/__main__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/__init__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_cli.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_output_safety.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_serialize.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_terminal.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_verdict.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_version.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/exit_codes.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/models.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/protocol.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/dispatcher.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/registry.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/__init__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/_registry.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/context.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/decorator.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/types.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/server.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/__init__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/agents/__init__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/agents/suggest.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/bpmn/__init__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/bpmn/generate.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/__init__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/dedupe.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/normalise.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/documents/__init__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/documents/parse.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/__init__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/_v1_corpus.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/get_record.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/resolve.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/retrieve.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/search.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/validate.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/__init__.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/knowledge_pack_loader.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/knowledge_search_engine.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/plm_skill_registry.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/plm_tool_definitions.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/skill_orchestrator.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/dependency_links.txt +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/entry_points.txt +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/requires.txt +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/top_level.txt +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/setup.cfg +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_agents_suggest_asgi.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_agents_suggest_unit.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_dispatcher.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_documents_parse_asgi.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_documents_parse_unit.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_sdk_decorator.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_server.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_skills_cleansing.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_skills_knowledge.py +0 -0
- {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_skills_knowledge_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plm-skill-kernel
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: TracePulse PLM Skill Kernel — Wave 2 Conv A. Decorator-based Skill SDK + V1 in-process dispatcher + Kernel HTTP server stub matching CR.10 §7.bis (POST /v1/skills/{id}/invoke). Ships 3 starter skills (cleansing.normalise, cleansing.dedupe, bpmn.generate). Sibling of plm-engine-core; the two communicate over the V1.1 HTTP loopback (SKILL_KERNEL_LOOPBACK=on) — no Python-level coupling at the dispatch boundary.
|
|
5
5
|
Author: TracePulse
|
|
6
6
|
License: Proprietary
|
|
@@ -14,7 +14,7 @@ sanctioned interaction is the V1.1 HTTP loopback (env-flagged on
|
|
|
14
14
|
``SKILL_KERNEL_LOOPBACK``); any Python-level reach-in is forbidden by
|
|
15
15
|
the import-linter contract in ``pyproject.toml``.
|
|
16
16
|
"""
|
|
17
|
-
__version__ = "1.
|
|
17
|
+
__version__ = "1.2.0"
|
|
18
18
|
|
|
19
19
|
# The decorator + sdk surface is the public authoring API. Re-exported
|
|
20
20
|
# here so callers write ``from plm_skill_kernel import skill`` rather
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""MVP skill-executor package — ordered-task execution over the V1 kernel.
|
|
2
|
+
|
|
3
|
+
This package is a **net-new, demo-scoped** layer built for the Runnable
|
|
4
|
+
Skill-Kernel MVP (backlog **FTR-2231** / story **FTR-2232 / E1**). It sits
|
|
5
|
+
*on top of* the existing V1 primitives without changing any of them:
|
|
6
|
+
|
|
7
|
+
* it reuses :class:`plm_skill_kernel.dispatcher.InProcessDispatcher` to run
|
|
8
|
+
each task (the dispatcher still invokes exactly one callable per call);
|
|
9
|
+
* it does **not** touch the frozen wire shapes
|
|
10
|
+
(:mod:`plm_skill_kernel.sdk.types`) or the frozen
|
|
11
|
+
:class:`plm_skill_kernel.sdk.context.SkillContext`;
|
|
12
|
+
* foundation knowledge is threaded through the inter-task *payload*
|
|
13
|
+
envelope (a reserved ``foundation`` key), never by mutating the governed
|
|
14
|
+
context model — so the whole layer is additive and reversible.
|
|
15
|
+
|
|
16
|
+
The demo thesis this enables: *an agent delegates a request to a
|
|
17
|
+
well-known, certified, controlled skill composed of an ordered set of
|
|
18
|
+
tasks, instead of solving the request itself.* The multi-task skill is the
|
|
19
|
+
deliberate highlight; see the MVP plan in ``.claude/thoughts``.
|
|
20
|
+
|
|
21
|
+
Nothing here is on the governed ``POST /v1/skills/{id}/invoke`` critical
|
|
22
|
+
path; the executor is driven by the demo entrypoint + tests.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .binding import BindingNotFound, resolve_binding
|
|
27
|
+
from .executor import SkillExecutor, TaskFailure
|
|
28
|
+
from .knowledge import EchoKnowledgeProvider, KnowledgeProvider, NullKnowledgeProvider
|
|
29
|
+
from .plan import SkillPlan, TaskStep
|
|
30
|
+
from .request import REQUEST_TEXT_KEY, parse_request
|
|
31
|
+
from .selector import UnroutableRequest, select_skill
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BindingNotFound",
|
|
35
|
+
"EchoKnowledgeProvider",
|
|
36
|
+
"KnowledgeProvider",
|
|
37
|
+
"NullKnowledgeProvider",
|
|
38
|
+
"REQUEST_TEXT_KEY",
|
|
39
|
+
"SkillExecutor",
|
|
40
|
+
"SkillPlan",
|
|
41
|
+
"TaskFailure",
|
|
42
|
+
"TaskStep",
|
|
43
|
+
"UnroutableRequest",
|
|
44
|
+
"parse_request",
|
|
45
|
+
"resolve_binding",
|
|
46
|
+
"select_skill",
|
|
47
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Demo catalog fixture (FTR-2232 / E1).
|
|
2
|
+
|
|
3
|
+
# MOCK(MVP-DEMO): this is a demo-scoped skill *catalog*, not the governed
|
|
4
|
+
# skill registry or the ``manifest-profile/1.0`` authoring surface. It maps a
|
|
5
|
+
# composite skill id to its ordered :class:`SkillPlan`. The concrete,
|
|
6
|
+
# *certified* demo skill package is story FTR-2235 / E4 — it replaces /
|
|
7
|
+
# extends this fixture with a data-dependent, human-signed-off plan. E1 ships
|
|
8
|
+
# only a structurally-valid, resolvable entry so ``resolve_binding`` has
|
|
9
|
+
# something to resolve and the seam is exercised end to end.
|
|
10
|
+
|
|
11
|
+
The demo plan below composes two already-registered V1 knowledge skills into
|
|
12
|
+
one ordered, two-task composite skill and threads a foundation-knowledge
|
|
13
|
+
lookup into the first task — enough to demonstrate the executor mechanism.
|
|
14
|
+
The ``certified`` / ``controlled`` flags are advisory demo values (present,
|
|
15
|
+
unenforced); they must never be read as real ratifications.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Dict, Tuple
|
|
20
|
+
|
|
21
|
+
from .plan import SkillPlan, TaskStep
|
|
22
|
+
|
|
23
|
+
# A demo corpus id understood by the in-process knowledge seed corpus.
|
|
24
|
+
_DEMO_CORPUS = "foundation"
|
|
25
|
+
|
|
26
|
+
DEMO_SKILL_ID = "demo.knowledge_lookup"
|
|
27
|
+
DEMO_SKILL_VERSION = "0.1.0"
|
|
28
|
+
|
|
29
|
+
_DEMO_PLAN = SkillPlan(
|
|
30
|
+
skill_id=DEMO_SKILL_ID,
|
|
31
|
+
version=DEMO_SKILL_VERSION,
|
|
32
|
+
description=(
|
|
33
|
+
"Demo composite skill: search the foundation corpus, then fetch a "
|
|
34
|
+
"record — an ordered two-task skill the agent delegates to."
|
|
35
|
+
),
|
|
36
|
+
# MOCK(MVP-DEMO): fixture governance flags — not real certification.
|
|
37
|
+
certified=True,
|
|
38
|
+
controlled=True,
|
|
39
|
+
tasks=(
|
|
40
|
+
TaskStep(
|
|
41
|
+
task_id="search",
|
|
42
|
+
skill_id="knowledge.search",
|
|
43
|
+
version="1.0.0",
|
|
44
|
+
description="Ordered task 1 — semantic search over the corpus.",
|
|
45
|
+
static_payload={"corpus_id": _DEMO_CORPUS, "top_k": 5},
|
|
46
|
+
input_keys=("query",),
|
|
47
|
+
knowledge_query="foundation overview",
|
|
48
|
+
output_key="search_result",
|
|
49
|
+
),
|
|
50
|
+
TaskStep(
|
|
51
|
+
task_id="get_record",
|
|
52
|
+
skill_id="knowledge.get_record",
|
|
53
|
+
version="1.0.0",
|
|
54
|
+
description="Ordered task 2 — fetch a specific record by id.",
|
|
55
|
+
static_payload={"corpus_id": _DEMO_CORPUS},
|
|
56
|
+
input_keys=("record_id",),
|
|
57
|
+
output_key="record_result",
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Catalog is keyed by (skill_id, version); a None-version lookup resolves to
|
|
64
|
+
# the sole registered version for that id (demo convenience).
|
|
65
|
+
DEMO_CATALOG: Dict[Tuple[str, str], SkillPlan] = {
|
|
66
|
+
(DEMO_SKILL_ID, DEMO_SKILL_VERSION): _DEMO_PLAN,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_demo_catalog() -> Dict[Tuple[str, str], SkillPlan]:
|
|
71
|
+
"""Return a fresh copy of the demo catalog (callers may extend it)."""
|
|
72
|
+
return dict(DEMO_CATALOG)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"DEMO_CATALOG",
|
|
77
|
+
"DEMO_SKILL_ID",
|
|
78
|
+
"DEMO_SKILL_VERSION",
|
|
79
|
+
"build_demo_catalog",
|
|
80
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""``resolve_binding`` — resolve a composite skill id to its ordered plan.
|
|
2
|
+
|
|
3
|
+
FTR-2232 / E1. The V1 SDK registry only maps ``(skill_id, version)`` to a
|
|
4
|
+
single callable (``_Registry.resolve``); there is no notion of resolving a
|
|
5
|
+
skill to a *plan of tasks*. ``resolve_binding`` is that thin missing lookup,
|
|
6
|
+
kept deliberately separate from the callable registry: it resolves against a
|
|
7
|
+
**catalog** of :class:`SkillPlan` fixtures (see :mod:`._catalog_fixture`).
|
|
8
|
+
|
|
9
|
+
It is intentionally minimal — a dict lookup with a None-version convenience —
|
|
10
|
+
so it can later be backed by a real catalog service behind the same
|
|
11
|
+
signature without touching callers.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Dict, Mapping, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from .plan import SkillPlan
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BindingNotFound(LookupError):
|
|
21
|
+
"""Raised when no catalog plan matches ``(skill_id, version)``."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_binding(
|
|
25
|
+
skill_id: str,
|
|
26
|
+
version: Optional[str] = None,
|
|
27
|
+
*,
|
|
28
|
+
catalog: Mapping[Tuple[str, str], SkillPlan],
|
|
29
|
+
) -> SkillPlan:
|
|
30
|
+
"""Resolve ``skill_id`` (+ optional ``version``) to a :class:`SkillPlan`.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
skill_id: composite skill id the agent delegates to.
|
|
34
|
+
version: exact version to resolve. When ``None``, the sole plan
|
|
35
|
+
registered under ``skill_id`` is returned; ambiguity (more than
|
|
36
|
+
one version) is an error so the caller is forced to be explicit.
|
|
37
|
+
catalog: mapping of ``(skill_id, version)`` → plan (the fixture).
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
BindingNotFound: no plan for that id (and version, if given), or a
|
|
41
|
+
``None`` version with multiple candidates.
|
|
42
|
+
"""
|
|
43
|
+
if version is not None:
|
|
44
|
+
plan = catalog.get((skill_id, version))
|
|
45
|
+
if plan is None:
|
|
46
|
+
raise BindingNotFound(
|
|
47
|
+
f"no plan bound for skill_id={skill_id!r} version={version!r}"
|
|
48
|
+
)
|
|
49
|
+
return plan
|
|
50
|
+
|
|
51
|
+
candidates: Dict[str, SkillPlan] = {
|
|
52
|
+
v: p for (sid, v), p in catalog.items() if sid == skill_id
|
|
53
|
+
}
|
|
54
|
+
if not candidates:
|
|
55
|
+
raise BindingNotFound(f"no plan bound for skill_id={skill_id!r}")
|
|
56
|
+
if len(candidates) > 1:
|
|
57
|
+
raise BindingNotFound(
|
|
58
|
+
f"skill_id={skill_id!r} has {len(candidates)} versions "
|
|
59
|
+
f"({sorted(candidates)}); specify one"
|
|
60
|
+
)
|
|
61
|
+
return next(iter(candidates.values()))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["BindingNotFound", "resolve_binding"]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Thin ordered-task executor (FTR-2232 / E1).
|
|
2
|
+
|
|
3
|
+
The V1 kernel dispatcher invokes exactly one callable per call. This
|
|
4
|
+
executor is the missing layer above it: it runs a :class:`SkillPlan`'s tasks
|
|
5
|
+
**in order**, threading a running ``state`` dict and foundation knowledge
|
|
6
|
+
between them, and reusing :class:`InProcessDispatcher` for every task. It
|
|
7
|
+
adds no new dispatch path — each task is an ordinary registered skill
|
|
8
|
+
resolved through the SDK registry.
|
|
9
|
+
|
|
10
|
+
Contract discipline:
|
|
11
|
+
|
|
12
|
+
* the frozen wire shapes (:mod:`plm_skill_kernel.sdk.types`) and
|
|
13
|
+
:class:`SkillContext` are used **unchanged**;
|
|
14
|
+
* foundation knowledge is injected into a task's payload under the reserved
|
|
15
|
+
``foundation`` key (see :mod:`.knowledge`), never onto the context;
|
|
16
|
+
* a task that fails (dispatcher returns ``ok=False``) stops the chain and
|
|
17
|
+
the executor returns that task's error envelope verbatim, annotated with
|
|
18
|
+
the failing ``task_id`` — the composite skill fails closed.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from typing import Any, Dict, Optional
|
|
24
|
+
|
|
25
|
+
from ..dispatcher import InProcessDispatcher
|
|
26
|
+
from ..sdk.context import SkillContext
|
|
27
|
+
from ..sdk.types import SkillErrorEnvelope, SkillInvokeRequest, SkillInvokeResult
|
|
28
|
+
from .knowledge import KnowledgeProvider, NullKnowledgeProvider
|
|
29
|
+
from .plan import SkillPlan, TaskStep
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Reserved payload key under which foundation snippets are threaded.
|
|
34
|
+
FOUNDATION_KEY = "foundation"
|
|
35
|
+
|
|
36
|
+
# Error code for an executor-level plan failure (a task returned ok=False).
|
|
37
|
+
# Reuses the KERNEL_* namespace convention; demo-scoped.
|
|
38
|
+
EXECUTOR_TASK_FAILED = "EXECUTOR_TASK_FAILED"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TaskFailure(Exception):
|
|
42
|
+
"""Raised internally when a task returns ``ok=False``; converted to a
|
|
43
|
+
:class:`SkillInvokeResult` at the executor boundary.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, task_id: str, error: Optional[SkillErrorEnvelope]) -> None:
|
|
47
|
+
self.task_id = task_id
|
|
48
|
+
self.error = error
|
|
49
|
+
super().__init__(f"task {task_id!r} failed")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SkillExecutor:
|
|
53
|
+
"""Runs a :class:`SkillPlan`'s ordered tasks over the V1 dispatcher.
|
|
54
|
+
|
|
55
|
+
Construct with an explicit dispatcher + knowledge provider so tests can
|
|
56
|
+
isolate registration state and assert knowledge threading. Both default
|
|
57
|
+
to safe production-ish values (the module registry dispatcher, and a
|
|
58
|
+
no-op knowledge provider).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
dispatcher: Optional[InProcessDispatcher] = None,
|
|
64
|
+
knowledge: Optional[KnowledgeProvider] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._dispatcher = dispatcher if dispatcher is not None else InProcessDispatcher()
|
|
67
|
+
self._knowledge = knowledge if knowledge is not None else NullKnowledgeProvider()
|
|
68
|
+
|
|
69
|
+
async def run(
|
|
70
|
+
self,
|
|
71
|
+
plan: SkillPlan,
|
|
72
|
+
initial_state: Optional[Dict[str, Any]] = None,
|
|
73
|
+
*,
|
|
74
|
+
context: SkillContext,
|
|
75
|
+
) -> SkillInvokeResult:
|
|
76
|
+
"""Execute ``plan`` end-to-end.
|
|
77
|
+
|
|
78
|
+
Returns a :class:`SkillInvokeResult`. On success ``result`` is the
|
|
79
|
+
final running state plus a ``_trace`` list recording each task's id,
|
|
80
|
+
skill, and whether knowledge was injected — so the demo can *show*
|
|
81
|
+
that the agent delegated to a multi-task skill. On the first task
|
|
82
|
+
failure the executor stops and surfaces that task's error.
|
|
83
|
+
"""
|
|
84
|
+
state: Dict[str, Any] = dict(initial_state or {})
|
|
85
|
+
trace: list[Dict[str, Any]] = []
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
for step in plan.tasks:
|
|
89
|
+
state = await self._run_task(step, state, context, trace)
|
|
90
|
+
except TaskFailure as tf:
|
|
91
|
+
return SkillInvokeResult(
|
|
92
|
+
ok=False,
|
|
93
|
+
error=tf.error
|
|
94
|
+
or SkillErrorEnvelope(
|
|
95
|
+
error_code=EXECUTOR_TASK_FAILED,
|
|
96
|
+
message=f"task {tf.task_id!r} failed without an error envelope",
|
|
97
|
+
detail={"task_id": tf.task_id},
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
state["_trace"] = trace
|
|
102
|
+
return SkillInvokeResult(ok=True, result=state)
|
|
103
|
+
|
|
104
|
+
async def _run_task(
|
|
105
|
+
self,
|
|
106
|
+
step: TaskStep,
|
|
107
|
+
state: Dict[str, Any],
|
|
108
|
+
context: SkillContext,
|
|
109
|
+
trace: list[Dict[str, Any]],
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
payload = self._build_payload(step, state)
|
|
112
|
+
|
|
113
|
+
injected = False
|
|
114
|
+
if step.knowledge_query:
|
|
115
|
+
snippets = self._knowledge.fetch(step.knowledge_query)
|
|
116
|
+
# Inject even when empty so tasks can rely on the key existing.
|
|
117
|
+
payload[FOUNDATION_KEY] = snippets
|
|
118
|
+
injected = bool(snippets)
|
|
119
|
+
|
|
120
|
+
request = SkillInvokeRequest(
|
|
121
|
+
payload=payload,
|
|
122
|
+
version=step.version,
|
|
123
|
+
autonomy_hint=context.autonomy_level,
|
|
124
|
+
)
|
|
125
|
+
# Per-task context mirrors the composite context but names the task's
|
|
126
|
+
# own skill id/version (the frozen model is rebuilt, never mutated).
|
|
127
|
+
task_ctx = context.model_copy(
|
|
128
|
+
update={"skill_id": step.skill_id, "skill_version": step.version}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
result = await self._dispatcher.dispatch(
|
|
132
|
+
step.skill_id, request, context=task_ctx
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
trace.append(
|
|
136
|
+
{
|
|
137
|
+
"task_id": step.task_id,
|
|
138
|
+
"skill_id": step.skill_id,
|
|
139
|
+
"version": step.version,
|
|
140
|
+
"knowledge_injected": injected,
|
|
141
|
+
"ok": result.ok,
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if not result.ok:
|
|
146
|
+
raise TaskFailure(step.task_id, result.error)
|
|
147
|
+
|
|
148
|
+
return self._merge_result(step, state, result.result or {})
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _build_payload(step: TaskStep, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
152
|
+
if step.input_keys:
|
|
153
|
+
payload = {k: state[k] for k in step.input_keys if k in state}
|
|
154
|
+
else:
|
|
155
|
+
payload = dict(state)
|
|
156
|
+
# Never leak the executor's own trace into a task payload.
|
|
157
|
+
payload.pop("_trace", None)
|
|
158
|
+
payload.update(step.static_payload)
|
|
159
|
+
return payload
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _merge_result(
|
|
163
|
+
step: TaskStep, state: Dict[str, Any], result: Dict[str, Any]
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
new_state = dict(state)
|
|
166
|
+
if step.output_key:
|
|
167
|
+
new_state[step.output_key] = result
|
|
168
|
+
else:
|
|
169
|
+
new_state.update(result)
|
|
170
|
+
return new_state
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = ["EXECUTOR_TASK_FAILED", "FOUNDATION_KEY", "SkillExecutor", "TaskFailure"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Foundation-knowledge seam for the MVP executor (FTR-2232 / E1).
|
|
2
|
+
|
|
3
|
+
E1 delivers **the seam and the threading**, not the data. The concrete
|
|
4
|
+
"simple-fake" foundation dataset is story **FTR-2233 / E2** and plugs in
|
|
5
|
+
behind :class:`KnowledgeProvider` without touching the executor.
|
|
6
|
+
|
|
7
|
+
Design choice (reversible): knowledge is threaded into each task through the
|
|
8
|
+
inter-task **payload** envelope under the reserved ``foundation`` key — the
|
|
9
|
+
executor calls ``provider.fetch(query)`` and injects the result. This keeps
|
|
10
|
+
the frozen :class:`plm_skill_kernel.sdk.context.SkillContext` untouched. When
|
|
11
|
+
the real ``plm-knowledge`` corpus is wired later, only the provider
|
|
12
|
+
implementation changes; the executor and the demo skill's tasks do not.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Dict, List, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class KnowledgeProvider(Protocol):
|
|
21
|
+
"""Minimal foundation-knowledge lookup surface.
|
|
22
|
+
|
|
23
|
+
A provider maps a free-text ``query`` to an ordered list of foundation
|
|
24
|
+
snippets (most relevant first). Each snippet is a plain JSON-able dict so
|
|
25
|
+
it threads cleanly through the task payload; the demo skill decides how
|
|
26
|
+
to read it. Implementations MUST be side-effect free and deterministic
|
|
27
|
+
for a given query (the demo must be reproducible).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def fetch(self, query: str) -> List[Dict[str, Any]]:
|
|
31
|
+
"""Return foundation snippets relevant to ``query`` (may be empty)."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NullKnowledgeProvider:
|
|
36
|
+
"""Provider that always returns no snippets.
|
|
37
|
+
|
|
38
|
+
The executor's default when no knowledge is wired — a plan whose tasks
|
|
39
|
+
set no ``knowledge_query`` runs identically with or without it.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def fetch(self, query: str) -> List[Dict[str, Any]]: # noqa: D401
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EchoKnowledgeProvider:
|
|
47
|
+
"""Trivial deterministic provider used only to prove the seam in E1.
|
|
48
|
+
|
|
49
|
+
Echoes the query back as a single snippet so E1 tests can assert that a
|
|
50
|
+
task actually received ``payload['foundation']``. **Not** the demo
|
|
51
|
+
dataset — E2 replaces this with the simple-fake foundation corpus.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def fetch(self, query: str) -> List[Dict[str, Any]]:
|
|
55
|
+
if not query:
|
|
56
|
+
return []
|
|
57
|
+
return [{"id": "echo-0", "query": query, "snippet": f"[echo] {query}"}]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"EchoKnowledgeProvider",
|
|
62
|
+
"KnowledgeProvider",
|
|
63
|
+
"NullKnowledgeProvider",
|
|
64
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""MVP task-list descriptor — "one skill → N ordered tasks".
|
|
2
|
+
|
|
3
|
+
**Net-new for the MVP (FTR-2232 / E1).** This is deliberately *not* a
|
|
4
|
+
change to :class:`plm_skill_kernel.sdk.types` (the frozen wire contract) nor
|
|
5
|
+
to the manifest / ``manifest-profile/1.0`` authoring surface — the V1 skill
|
|
6
|
+
manifest cannot express an ordered set of tasks, and adding that to the
|
|
7
|
+
governed profile is out of scope for the demo. Instead, a :class:`SkillPlan`
|
|
8
|
+
is a thin executor-side descriptor: it names, in order, the registered
|
|
9
|
+
kernel skills that make up one composite skill, plus (optionally) the
|
|
10
|
+
foundation-knowledge lookup to thread into each task.
|
|
11
|
+
|
|
12
|
+
A :class:`SkillPlan` therefore composes existing single-callable skills into
|
|
13
|
+
the multi-task skill the demo highlights. Each :class:`TaskStep` points at a
|
|
14
|
+
skill already in the SDK registry (``skill_id`` + ``version``); the executor
|
|
15
|
+
resolves and runs them through the ordinary dispatcher.
|
|
16
|
+
|
|
17
|
+
Both models are ``frozen`` + ``extra="forbid"`` to mirror the discipline of
|
|
18
|
+
the surrounding V1 contracts, but they live entirely in the demo layer.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Dict, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TaskStep(BaseModel):
|
|
28
|
+
"""One ordered task inside a :class:`SkillPlan`.
|
|
29
|
+
|
|
30
|
+
A task is a *reference* to a registered kernel skill; the executor runs
|
|
31
|
+
it via :class:`plm_skill_kernel.dispatcher.InProcessDispatcher`. Tasks
|
|
32
|
+
are chained by a running ``state`` dict (see :class:`SkillExecutor`):
|
|
33
|
+
|
|
34
|
+
* ``input_keys`` selects which keys of the running state are handed to
|
|
35
|
+
the task as its payload. Empty tuple ⇒ the whole running state is
|
|
36
|
+
passed (convenient default for short demo chains).
|
|
37
|
+
* ``static_payload`` is merged into the task payload verbatim (constants
|
|
38
|
+
the task needs that are not produced by an earlier task).
|
|
39
|
+
* ``knowledge_query`` — when set, the executor asks the knowledge
|
|
40
|
+
provider for foundation snippets and injects them into the task
|
|
41
|
+
payload under the reserved ``foundation`` key *before* dispatch.
|
|
42
|
+
* ``output_key`` — when set, the task's whole result dict is nested
|
|
43
|
+
under this key in the running state; otherwise the result is shallow
|
|
44
|
+
merged into the running state.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
48
|
+
|
|
49
|
+
task_id: str = Field(min_length=1, description="Stable id, unique within the plan.")
|
|
50
|
+
skill_id: str = Field(min_length=1, description="Registered skill to invoke.")
|
|
51
|
+
version: str = Field(min_length=1, description="Registered skill version.")
|
|
52
|
+
description: Optional[str] = None
|
|
53
|
+
input_keys: Tuple[str, ...] = Field(
|
|
54
|
+
default=(),
|
|
55
|
+
description=(
|
|
56
|
+
"Running-state keys to pass as this task's payload. Empty ⇒ pass "
|
|
57
|
+
"the entire running state."
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
static_payload: Dict[str, Any] = Field(
|
|
61
|
+
default_factory=dict,
|
|
62
|
+
description="Constant payload fields merged into the task input.",
|
|
63
|
+
)
|
|
64
|
+
knowledge_query: Optional[str] = Field(
|
|
65
|
+
default=None,
|
|
66
|
+
description=(
|
|
67
|
+
"Foundation-knowledge lookup to thread into this task. When set, "
|
|
68
|
+
"the executor injects results under payload['foundation']."
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
output_key: Optional[str] = Field(
|
|
72
|
+
default=None,
|
|
73
|
+
description=(
|
|
74
|
+
"Nest the task result under this state key. Unset ⇒ shallow-merge "
|
|
75
|
+
"the result into the running state."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SkillPlan(BaseModel):
|
|
81
|
+
"""An ordered set of tasks that together form one composite skill.
|
|
82
|
+
|
|
83
|
+
``skill_id`` / ``version`` identify the *composite* skill as the agent
|
|
84
|
+
knows it (the thing it delegates to); ``tasks`` is the ordered
|
|
85
|
+
decomposition the executor runs. ``certified`` / ``controlled`` are
|
|
86
|
+
demo-fixture governance flags — they are advisory here and MUST NOT be
|
|
87
|
+
treated as real ratifications (see the governance firewall in the MVP
|
|
88
|
+
plan; enforcement is intentionally absent — V0 fields-present-unenforced).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
92
|
+
|
|
93
|
+
skill_id: str = Field(min_length=1)
|
|
94
|
+
version: str = Field(min_length=1)
|
|
95
|
+
description: Optional[str] = None
|
|
96
|
+
tasks: Tuple[TaskStep, ...] = Field(
|
|
97
|
+
min_length=1,
|
|
98
|
+
description="Ordered tasks; executed front-to-back.",
|
|
99
|
+
)
|
|
100
|
+
# MOCK(MVP-DEMO): demo-fixture governance flags. Real certification /
|
|
101
|
+
# control status requires human sign-off and lives in the FTR-2034
|
|
102
|
+
# register + published profile, never here. Present-but-unenforced.
|
|
103
|
+
certified: bool = False
|
|
104
|
+
controlled: bool = False
|
|
105
|
+
|
|
106
|
+
def task_ids(self) -> Tuple[str, ...]:
|
|
107
|
+
return tuple(t.task_id for t in self.tasks)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["SkillPlan", "TaskStep"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Request text → executor initial state (FTR-2232 / E1).
|
|
2
|
+
|
|
3
|
+
The demo's agent request path has two halves:
|
|
4
|
+
|
|
5
|
+
* :func:`plm_skill_kernel.executor.select_skill` decides *which* composite
|
|
6
|
+
skill to delegate to (a fixed keyword mapping — see :mod:`.selector`);
|
|
7
|
+
* :func:`parse_request` decides *what starting data* that skill runs against,
|
|
8
|
+
by turning the raw request text into the executor's initial running state.
|
|
9
|
+
|
|
10
|
+
Before this seam the request text only drove selection; every task input was
|
|
11
|
+
hardcoded, so the demo ran the same regardless of what was asked. This makes
|
|
12
|
+
the request text actually feed :meth:`SkillExecutor.run`'s ``initial_state``.
|
|
13
|
+
|
|
14
|
+
# MOCK(MVP-DEMO): real natural-language understanding (entity extraction,
|
|
15
|
+
# intent slots) is Core's job and is **not** wired here. This is a
|
|
16
|
+
# deliberately trivial, deterministic parse — the whole request becomes the
|
|
17
|
+
# ``query``, and an id-like token is lifted into ``record_id`` when the
|
|
18
|
+
# request names one. It is legible and swappable for real parsing later
|
|
19
|
+
# behind the same signature, exactly like :func:`select_skill`.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any, Dict, Optional
|
|
25
|
+
|
|
26
|
+
# Reserved state key holding the verbatim request text, for traceability in
|
|
27
|
+
# the executor trace and for tasks that want the original wording.
|
|
28
|
+
REQUEST_TEXT_KEY = "request_text"
|
|
29
|
+
|
|
30
|
+
# MOCK(MVP-DEMO): lift an id-like token when the request explicitly names a
|
|
31
|
+
# record/part/id, e.g. "get record R-100", "part PN-4523", "id: ABC_42".
|
|
32
|
+
# First match wins; absence simply leaves ``record_id`` unset (the demo's
|
|
33
|
+
# get_record task then relies on its own static defaults / upstream output).
|
|
34
|
+
_RECORD_ID_RE = re.compile(
|
|
35
|
+
r"(?:record[_ ]?id|record|part(?:[_ ]?number)?|id)\s*[:#=]?\s*"
|
|
36
|
+
r"(?P<id>[A-Za-z]*[-_]?\d[A-Za-z0-9._-]*)",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_request(
|
|
42
|
+
request_text: str,
|
|
43
|
+
*,
|
|
44
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
"""Turn ``request_text`` into the executor's initial running state.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
request_text: the raw inbound request the agent received.
|
|
50
|
+
extra: optional pre-known state to merge in last (wins over parsed
|
|
51
|
+
values) — e.g. ids the caller already resolved out of band.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
A plain, JSON-able ``initial_state`` dict suitable as
|
|
55
|
+
:meth:`SkillExecutor.run`'s ``initial_state``. Always contains
|
|
56
|
+
``request_text`` and ``query``; contains ``record_id`` only when the
|
|
57
|
+
request named one (or ``extra`` supplied it).
|
|
58
|
+
"""
|
|
59
|
+
cleaned = (request_text or "").strip()
|
|
60
|
+
|
|
61
|
+
state: Dict[str, Any] = {
|
|
62
|
+
REQUEST_TEXT_KEY: cleaned,
|
|
63
|
+
# The search task consumes ``query`` (input_keys=("query",)); using the
|
|
64
|
+
# whole request keeps the demo honest — different requests search for
|
|
65
|
+
# different things.
|
|
66
|
+
"query": cleaned,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
match = _RECORD_ID_RE.search(cleaned)
|
|
70
|
+
if match:
|
|
71
|
+
state["record_id"] = match.group("id")
|
|
72
|
+
|
|
73
|
+
if extra:
|
|
74
|
+
state.update(extra)
|
|
75
|
+
|
|
76
|
+
return state
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["REQUEST_TEXT_KEY", "parse_request"]
|