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.
Files changed (75) hide show
  1. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/PKG-INFO +1 -1
  2. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/__init__.py +1 -1
  3. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/__init__.py +47 -0
  4. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/_catalog_fixture.py +80 -0
  5. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/binding.py +64 -0
  6. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/executor.py +173 -0
  7. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/knowledge.py +64 -0
  8. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/plan.py +110 -0
  9. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/request.py +79 -0
  10. plm_skill_kernel-1.2.0/plm_skill_kernel/executor/selector.py +59 -0
  11. plm_skill_kernel-1.2.0/plm_skill_kernel/mocks/__init__.py +53 -0
  12. plm_skill_kernel-1.2.0/plm_skill_kernel/mocks/foundation_seed.py +207 -0
  13. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/PKG-INFO +1 -1
  14. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/SOURCES.txt +12 -0
  15. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/pyproject.toml +1 -1
  16. plm_skill_kernel-1.2.0/tests/test_executor.py +308 -0
  17. plm_skill_kernel-1.2.0/tests/test_foundation_mocks.py +138 -0
  18. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/README.md +0 -0
  19. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/__main__.py +0 -0
  20. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/__init__.py +0 -0
  21. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_cli.py +0 -0
  22. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_output_safety.py +0 -0
  23. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_serialize.py +0 -0
  24. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_terminal.py +0 -0
  25. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_verdict.py +0 -0
  26. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/_version.py +0 -0
  27. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/exit_codes.py +0 -0
  28. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/models.py +0 -0
  29. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/conformance/protocol.py +0 -0
  30. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/dispatcher.py +0 -0
  31. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/registry.py +0 -0
  32. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/__init__.py +0 -0
  33. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/_registry.py +0 -0
  34. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/context.py +0 -0
  35. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/decorator.py +0 -0
  36. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/sdk/types.py +0 -0
  37. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/server.py +0 -0
  38. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/__init__.py +0 -0
  39. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/agents/__init__.py +0 -0
  40. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/agents/suggest.py +0 -0
  41. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/bpmn/__init__.py +0 -0
  42. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/bpmn/generate.py +0 -0
  43. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/__init__.py +0 -0
  44. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/dedupe.py +0 -0
  45. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/cleansing/normalise.py +0 -0
  46. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/documents/__init__.py +0 -0
  47. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/documents/parse.py +0 -0
  48. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/__init__.py +0 -0
  49. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/_v1_corpus.py +0 -0
  50. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/get_record.py +0 -0
  51. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/resolve.py +0 -0
  52. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/retrieve.py +0 -0
  53. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/search.py +0 -0
  54. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills/knowledge/validate.py +0 -0
  55. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/__init__.py +0 -0
  56. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/knowledge_pack_loader.py +0 -0
  57. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/knowledge_search_engine.py +0 -0
  58. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/plm_skill_registry.py +0 -0
  59. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/plm_tool_definitions.py +0 -0
  60. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel/skills_legacy/skill_orchestrator.py +0 -0
  61. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/dependency_links.txt +0 -0
  62. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/entry_points.txt +0 -0
  63. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/requires.txt +0 -0
  64. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/plm_skill_kernel.egg-info/top_level.txt +0 -0
  65. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/setup.cfg +0 -0
  66. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_agents_suggest_asgi.py +0 -0
  67. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_agents_suggest_unit.py +0 -0
  68. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_dispatcher.py +0 -0
  69. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_documents_parse_asgi.py +0 -0
  70. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_documents_parse_unit.py +0 -0
  71. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_sdk_decorator.py +0 -0
  72. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_server.py +0 -0
  73. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_skills_cleansing.py +0 -0
  74. {plm_skill_kernel-1.1.0 → plm_skill_kernel-1.2.0}/tests/test_skills_knowledge.py +0 -0
  75. {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.1.0
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.1.0"
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"]