unique-skill-tool 2026.22.0.dev4__tar.gz → 2026.22.0.dev6__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 (15) hide show
  1. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/PKG-INFO +3 -2
  2. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/pyproject.toml +3 -2
  3. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/__init__.py +2 -0
  4. unique_skill_tool-2026.22.0.dev6/unique_skill_tool/loader.py +137 -0
  5. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/schemas.py +20 -0
  6. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/service.py +31 -4
  7. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/README.md +0 -0
  8. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/config.py +0 -0
  9. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/examples/README.md +0 -0
  10. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/examples/analyze-data/SKILL.md +0 -0
  11. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/examples/analyze-factsheet/SKILL.md +0 -0
  12. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/examples/draft-email/SKILL.md +0 -0
  13. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/examples/review-contract/SKILL.md +0 -0
  14. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/prompts.py +0 -0
  15. {unique_skill_tool-2026.22.0.dev4 → unique_skill_tool-2026.22.0.dev6}/unique_skill_tool/utils.py +0 -0
@@ -1,13 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-skill-tool
3
- Version: 2026.22.0.dev4
3
+ Version: 2026.22.0.dev6
4
4
  Summary:
5
5
  Author: Fabian Schläpfer
6
6
  Author-email: Fabian Schläpfer <fabian@unique.ch>
7
7
  License: Proprietary
8
8
  Requires-Dist: jinja2>=3.1.0,<4
9
9
  Requires-Dist: pydantic>=2.8.2,<3
10
- Requires-Dist: unique-toolkit>=2026.22.0.dev6,<2026.22.0rc0
10
+ Requires-Dist: python-frontmatter>=1.1.0,<2
11
+ Requires-Dist: unique-toolkit>=2026.22.0.dev11,<2026.22.0rc0
11
12
  Requires-Python: >=3.12, <4
12
13
  Description-Content-Type: text/markdown
13
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_skill_tool"
3
- version = "2026.22.0.dev4"
3
+ version = "2026.22.0.dev6"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "Proprietary" }
@@ -11,7 +11,8 @@ requires-python = ">=3.12,<4"
11
11
  dependencies = [
12
12
  "jinja2>=3.1.0,<4",
13
13
  "pydantic>=2.8.2,<3",
14
- "unique-toolkit>=2026.22.0.dev6,<2026.22.0rc0",
14
+ "python-frontmatter>=1.1.0,<2",
15
+ "unique-toolkit>=2026.22.0.dev11,<2026.22.0rc0",
15
16
  ]
16
17
 
17
18
  [build-system]
@@ -1,4 +1,5 @@
1
1
  from unique_skill_tool.config import SkillToolConfig
2
+ from unique_skill_tool.loader import parse_skill_file
2
3
  from unique_skill_tool.schemas import SkillDefinition
3
4
  from unique_skill_tool.service import SkillTool
4
5
 
@@ -6,4 +7,5 @@ __all__ = [
6
7
  "SkillTool",
7
8
  "SkillToolConfig",
8
9
  "SkillDefinition",
10
+ "parse_skill_file",
9
11
  ]
@@ -0,0 +1,137 @@
1
+ """Parse SKILL.md files into ``SkillDefinition`` objects.
2
+
3
+ Every skill is a folder with a ``SKILL.md`` entrypoint that carries YAML
4
+ frontmatter::
5
+
6
+ ---
7
+ name: summarize-report
8
+ description: >-
9
+ Summarize a document into key findings.
10
+ metadata: # optional
11
+ thinking_level: high
12
+ ---
13
+
14
+ # Summarize Report
15
+ ...instructions...
16
+
17
+ :func:`parse_skill_file` is the single public entry point: it accepts the raw
18
+ text of one ``SKILL.md`` file and returns a ``SkillDefinition``, or ``None``
19
+ when the file is empty or malformed.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from logging import Logger
25
+
26
+ import frontmatter
27
+ from pydantic import ValidationError
28
+ from unique_toolkit.language_model.schemas import to_reasoning_effort
29
+
30
+ from unique_skill_tool.schemas import SkillDefinition, SkillMetadata
31
+
32
+
33
+ def _parse_frontmatter(*, text: str) -> tuple[dict[str, object], str]:
34
+ """Split YAML frontmatter from the markdown body.
35
+
36
+ Thin wrapper around ``python-frontmatter``. On a YAML parse error, or when
37
+ the frontmatter parses to a non-mapping (e.g. a top-level YAML list), falls
38
+ back to ``({}, <original text>)`` so a broken skill file never leaks raw
39
+ ``---\\nname: ...\\n---`` delimiters into the LLM prompt and never raises.
40
+ """
41
+ try:
42
+ post = frontmatter.loads(text)
43
+ body = post.content
44
+ return dict(post.metadata), body
45
+ except Exception:
46
+ return {}, text
47
+
48
+
49
+ def parse_skill_file(
50
+ *,
51
+ file_text: str,
52
+ content_id: str,
53
+ source_label: str = "",
54
+ logger: Logger | None = None,
55
+ ) -> SkillDefinition | None:
56
+ """Build a ``SkillDefinition`` from the raw text of a SKILL.md file.
57
+
58
+ Parses YAML frontmatter for ``name`` and ``description``. A malformed
59
+ ``name`` (non-kebab-case, contains whitespace/punctuation, too long) is
60
+ rejected by ``SkillDefinition`` validation rather than silently flowing into
61
+ the OpenAI tool enum where the model could never emit it verbatim.
62
+
63
+ Args:
64
+ file_text: Raw UTF-8 text of the SKILL.md file.
65
+ content_id: Knowledge-base content ID this skill was loaded from.
66
+ source_label: Human-readable identifier used only in warning messages
67
+ so callers can locate the offending file (e.g. a knowledge-base
68
+ content key or file path).
69
+ logger: Optional logger for diagnostic warnings. Pass ``None`` to
70
+ suppress all log output (useful in tests).
71
+
72
+ Returns:
73
+ A validated ``SkillDefinition``, or ``None`` when the file is empty,
74
+ missing required frontmatter fields, or fails schema validation.
75
+ """
76
+ if not file_text.strip():
77
+ return None
78
+
79
+ metadata, body = _parse_frontmatter(text=file_text)
80
+
81
+ name = metadata.get("name")
82
+ description = metadata.get("description")
83
+
84
+ if (
85
+ not isinstance(name, str)
86
+ or not isinstance(description, str)
87
+ or not name
88
+ or not description
89
+ ):
90
+ if logger is not None:
91
+ logger.warning(
92
+ "Skipping '%s': wrong skill format.",
93
+ source_label,
94
+ )
95
+ return None
96
+
97
+ skill_meta: SkillMetadata | None = None
98
+ raw_meta = metadata.get("metadata")
99
+ if raw_meta is not None and not isinstance(raw_meta, dict):
100
+ if logger is not None:
101
+ logger.warning(
102
+ "Skill '%s': 'metadata' must be a key-value mapping, got %r — ignoring.",
103
+ source_label,
104
+ type(raw_meta).__name__,
105
+ )
106
+ raw_meta = None
107
+ if isinstance(raw_meta, dict):
108
+ raw_thinking = raw_meta.get("thinking_level")
109
+ thinking_level = None
110
+ if raw_thinking is not None:
111
+ try:
112
+ thinking_level = to_reasoning_effort(str(raw_thinking))
113
+ except ValueError:
114
+ if logger is not None:
115
+ logger.warning(
116
+ "Skill '%s': unknown thinking_level %r — ignoring.",
117
+ source_label,
118
+ raw_thinking,
119
+ )
120
+ skill_meta = SkillMetadata(thinking_level=thinking_level)
121
+
122
+ try:
123
+ return SkillDefinition(
124
+ name=name,
125
+ description=description,
126
+ content=body,
127
+ content_id=content_id,
128
+ metadata=skill_meta,
129
+ )
130
+ except ValidationError as exc:
131
+ if logger is not None:
132
+ logger.warning(
133
+ "Skipping '%s': invalid skill definition: %s",
134
+ source_label,
135
+ exc.errors(include_url=False),
136
+ )
137
+ return None
@@ -2,11 +2,27 @@ from __future__ import annotations
2
2
 
3
3
  from pydantic import BaseModel, Field
4
4
  from unique_toolkit._common.pydantic_helpers import get_configuration_dict
5
+ from unique_toolkit.language_model.schemas import ReasoningEffort
5
6
 
6
7
  SKILL_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
7
8
  SKILL_NAME_MAX_LENGTH = 64
8
9
 
9
10
 
11
+ class SkillMetadata(BaseModel):
12
+ """Typed representation of a skill's SKILL.md ``metadata`` frontmatter block."""
13
+
14
+ model_config = get_configuration_dict()
15
+
16
+ thinking_level: ReasoningEffort | None = Field(
17
+ default=None,
18
+ description=(
19
+ "Optional reasoning effort hint. The orchestrator uses the highest "
20
+ "level across all activated skills in a run to set "
21
+ "``reasoning_effort`` on the LLM call."
22
+ ),
23
+ )
24
+
25
+
10
26
  class SkillDefinition(BaseModel):
11
27
  """A skill that the agent can activate via the SkillTool.
12
28
 
@@ -36,3 +52,7 @@ class SkillDefinition(BaseModel):
36
52
  content_id: str = Field(
37
53
  description="Knowledge-base content ID this skill was loaded from.",
38
54
  )
55
+ metadata: SkillMetadata | None = Field(
56
+ default=None,
57
+ description="Parsed ``metadata`` block from the skill's SKILL.md frontmatter.",
58
+ )
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import override
3
+ from typing import cast, override
4
4
 
5
5
  from jinja2.sandbox import SandboxedEnvironment
6
6
  from unique_toolkit.agentic.evaluation.schemas import EvaluationMetricName
@@ -11,14 +11,14 @@ from unique_toolkit.agentic.tools.tool_progress_reporter import ToolProgressRepo
11
11
  from unique_toolkit.app.schemas import ChatEvent
12
12
  from unique_toolkit.chat.schemas import MessageLog, MessageLogStatus
13
13
  from unique_toolkit.language_model.schemas import (
14
+ REASONING_EFFORT_ORDER,
14
15
  LanguageModelFunction,
15
16
  LanguageModelToolDescription,
17
+ ReasoningEffort,
16
18
  )
17
19
 
18
20
  from unique_skill_tool.config import SkillToolConfig
19
- from unique_skill_tool.schemas import (
20
- SkillDefinition,
21
- )
21
+ from unique_skill_tool.schemas import SkillDefinition
22
22
  from unique_skill_tool.utils import (
23
23
  format_skill_listing,
24
24
  normalize_skill_name,
@@ -46,6 +46,7 @@ class SkillTool(Tool[SkillToolConfig]):
46
46
  ) -> None:
47
47
  super().__init__(config, event, tool_progress_reporter)
48
48
  self._skill_registry: dict[str, SkillDefinition] = {}
49
+ self._activated_skills: list[SkillDefinition] = []
49
50
 
50
51
  @property
51
52
  def skill_registry(self) -> dict[str, SkillDefinition]:
@@ -55,6 +56,27 @@ class SkillTool(Tool[SkillToolConfig]):
55
56
  def skill_registry(self, value: dict[str, SkillDefinition]) -> None:
56
57
  self._skill_registry = value
57
58
 
59
+ @property
60
+ def activated_skills(self) -> list[SkillDefinition]:
61
+ """All skills successfully activated in this run, in activation order."""
62
+ return self._activated_skills
63
+
64
+ @property
65
+ def max_thinking_level(self) -> ReasoningEffort | None:
66
+ """Highest ``thinking_level`` across all skills activated in this run.
67
+
68
+ Returns ``None`` when no activated skill declares a ``thinking_level``.
69
+ The ordering is ``none < minimal < low < medium < high < xhigh``.
70
+ """
71
+ levels = [
72
+ s.metadata.thinking_level
73
+ for s in self._activated_skills
74
+ if s.metadata is not None and s.metadata.thinking_level is not None
75
+ ]
76
+ if not levels:
77
+ return None
78
+ return cast(ReasoningEffort, max(levels, key=REASONING_EFFORT_ORDER.index))
79
+
58
80
  @override
59
81
  def display_name(self) -> str:
60
82
  return "Skill"
@@ -123,6 +145,10 @@ class SkillTool(Tool[SkillToolConfig]):
123
145
  .render(skill_list=listing)
124
146
  )
125
147
 
148
+ @override
149
+ def is_capability(self) -> bool:
150
+ return True
151
+
126
152
  @override
127
153
  async def run(self, tool_call: LanguageModelFunction) -> ToolCallResponse:
128
154
  args = tool_call.arguments or {}
@@ -149,6 +175,7 @@ class SkillTool(Tool[SkillToolConfig]):
149
175
  ),
150
176
  )
151
177
 
178
+ self._activated_skills.append(skill)
152
179
  self._active_message_log = await self._log_skill_loaded(skill_name=skill_name)
153
180
 
154
181
  content_parts = [