unique-skill-tool 2026.16.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.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.3
2
+ Name: unique-skill-tool
3
+ Version: 2026.16.0
4
+ Summary:
5
+ Author: Fabian Schläpfer
6
+ Author-email: Fabian Schläpfer <fabian@unique.ch>
7
+ License: Proprietary
8
+ Requires-Dist: jinja2>=3.1.0,<4
9
+ Requires-Dist: pydantic>=2.8.2,<3
10
+ Requires-Dist: unique-toolkit>=1.47.7,<2
11
+ Requires-Python: >=3.12, <4
12
+ Description-Content-Type: text/markdown
13
+
14
+ # unique_skill_tool
@@ -0,0 +1 @@
1
+ # unique_skill_tool
@@ -0,0 +1,92 @@
1
+ [project]
2
+ name = "unique_skill_tool"
3
+ version = "2026.16.0"
4
+ description = ""
5
+ readme = "README.md"
6
+ license = { text = "Proprietary" }
7
+ authors = [
8
+ { name = "Fabian Schläpfer", email = "fabian@unique.ch" },
9
+ ]
10
+ requires-python = ">=3.12,<4"
11
+ dependencies = [
12
+ "jinja2>=3.1.0,<4",
13
+ "pydantic>=2.8.2,<3",
14
+ "unique-toolkit>=1.47.7,<2",
15
+ ]
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.8.14,<0.9.0"]
19
+ build-backend = "uv_build"
20
+
21
+ [tool.uv.build-backend]
22
+ module-root = ""
23
+
24
+ [tool.uv]
25
+ exclude-newer = "2 weeks"
26
+
27
+ [tool.uv.exclude-newer-package]
28
+ "unique-toolkit" = false
29
+
30
+ [dependency-groups]
31
+ dev = []
32
+
33
+ [tool.uv.sources]
34
+ unique-toolkit = { workspace = true }
35
+
36
+ [tool.poe.tasks]
37
+ lint = "ruff check ."
38
+ lint-fix = "ruff check . --fix"
39
+ format = "ruff format ."
40
+ test = "pytest"
41
+ typecheck = "basedpyright"
42
+ depcheck = "deptry ."
43
+ coverage = "pytest --cov=unique_skill_tool --cov-report=term-missing"
44
+ ci-typecheck = { shell = "bash $(git rev-parse --show-toplevel)/.github/scripts/dev.sh typecheck" }
45
+ ci-coverage = { shell = "bash $(git rev-parse --show-toplevel)/.github/scripts/dev.sh coverage" }
46
+
47
+ [tool.deptry]
48
+ known_first_party = ["unique_skill_tool"]
49
+ extend_exclude = ["tests"]
50
+
51
+ [tool.deptry.per_rule_ignores]
52
+ DEP002 = ["unique-toolkit"]
53
+ DEP003 = ["unique_toolkit"]
54
+
55
+ [tool.basedpyright]
56
+ typeCheckingMode = "recommended"
57
+ include = ["unique_skill_tool"]
58
+ exclude = ["**/tests/**", "**/test_*.py"]
59
+
60
+ # Any-cascade: originates from jinja2 and LanguageModelFunction.arguments in
61
+ # unique_toolkit; not fixable from this package.
62
+ reportAny = "none"
63
+ reportUnknownVariableType = "none"
64
+ reportUnknownMemberType = "none"
65
+ reportUnknownArgumentType = "none"
66
+
67
+ # unique_toolkit submodules ship no inline stubs; suppressed identically to toolkit.
68
+ reportMissingTypeStubs = "none"
69
+
70
+ # base-class/mixin patterns make per-attribute annotations impractical.
71
+ reportUnannotatedClassAttribute = "none"
72
+
73
+ [tool.pytest.ini_options]
74
+ addopts = "--strict-markers --import-mode=importlib"
75
+ asyncio_mode = "auto"
76
+ markers = [
77
+ "ai: AI-authored or AI-generated tests",
78
+ "asyncio: asyncio tests",
79
+ "integration: integration tests that require API access or credentials",
80
+ "serial: tests that must run serially",
81
+ "unit: unit tests",
82
+ "verified: AI-generated tests with human verification",
83
+ ]
84
+ filterwarnings = [
85
+ "ignore::DeprecationWarning",
86
+ ]
87
+
88
+ [tool.ruff]
89
+ target-version = "py311"
90
+
91
+ [tool.ruff.lint]
92
+ extend-select = ["I"]
@@ -0,0 +1,9 @@
1
+ from unique_skill_tool.config import SkillToolConfig
2
+ from unique_skill_tool.schemas import SkillDefinition
3
+ from unique_skill_tool.service import SkillTool
4
+
5
+ __all__ = [
6
+ "SkillTool",
7
+ "SkillToolConfig",
8
+ "SkillDefinition",
9
+ ]
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import Field
6
+ from unique_toolkit._common.pydantic.rjsf_tags import RJSFMetaTag
7
+ from unique_toolkit.agentic.tools.schemas import BaseToolConfig
8
+
9
+ from unique_skill_tool.prompts import (
10
+ DEFAULT_TOOL_DESCRIPTION,
11
+ DEFAULT_TOOL_DESCRIPTION_FOR_SYSTEM_PROMPT,
12
+ DEFAULT_TOOL_DESCRIPTION_FOR_USER_PROMPT,
13
+ DEFAULT_TOOL_PARAMETER_ARGUMENTS_DESCRIPTION,
14
+ DEFAULT_TOOL_PARAMETER_SKILL_NAME_DESCRIPTION,
15
+ DEFAULT_TOOL_SYSTEM_REMINDER_FOR_USER_MESSAGE,
16
+ )
17
+
18
+ CHARS_PER_TOKEN = 4
19
+ DEFAULT_CHAR_BUDGET = 8_000
20
+ SKILL_BUDGET_CONTEXT_PERCENT = 0.03
21
+ MAX_LISTING_DESC_CHARS = 250
22
+
23
+
24
+ class SkillToolConfig(BaseToolConfig):
25
+ """Configuration for the Skill tool."""
26
+
27
+ enabled: Annotated[
28
+ bool,
29
+ RJSFMetaTag.BooleanWidget.checkbox(
30
+ help=(
31
+ "Master switch for the Skill tool. When disabled, the tool "
32
+ "is not registered and skills are not available to the agent."
33
+ ),
34
+ ),
35
+ ] = Field(
36
+ default=False,
37
+ description="Enable the Skill tool.",
38
+ )
39
+
40
+ tool_description: Annotated[
41
+ str,
42
+ RJSFMetaTag.StringWidget.textarea(rows=3),
43
+ ] = Field(
44
+ default=DEFAULT_TOOL_DESCRIPTION,
45
+ description="The LLM-facing description of the Skill tool.",
46
+ )
47
+
48
+ tool_description_for_system_prompt: Annotated[
49
+ str,
50
+ RJSFMetaTag.StringWidget.textarea(rows=7),
51
+ ] = Field(
52
+ default=DEFAULT_TOOL_DESCRIPTION_FOR_SYSTEM_PROMPT,
53
+ description="Instructions for the system prompt explaining how to use skills.",
54
+ )
55
+
56
+ tool_description_for_user_prompt: Annotated[
57
+ str,
58
+ RJSFMetaTag.StringWidget.textarea(rows=5),
59
+ ] = Field(
60
+ default=DEFAULT_TOOL_DESCRIPTION_FOR_USER_PROMPT,
61
+ description=(
62
+ "Optional extra text appended to the per-turn user-message injection.."
63
+ ),
64
+ )
65
+
66
+ tool_system_reminder_for_user_message: Annotated[
67
+ str,
68
+ RJSFMetaTag.StringWidget.textarea(rows=5),
69
+ ] = Field(
70
+ default=DEFAULT_TOOL_SYSTEM_REMINDER_FOR_USER_MESSAGE,
71
+ description=(
72
+ "Per-turn ``<system-reminder>`` template injected into the "
73
+ "user message. Jinja variable ``skill_list`` is rendered "
74
+ "with the budget-aware skill listing. Refreshed every loop "
75
+ "iteration."
76
+ ),
77
+ )
78
+
79
+ tool_parameter_description_skill_name: Annotated[
80
+ str,
81
+ RJSFMetaTag.StringWidget.textarea(rows=2),
82
+ ] = Field(
83
+ default=DEFAULT_TOOL_PARAMETER_SKILL_NAME_DESCRIPTION,
84
+ description="The description of the 'skill_name' parameter.",
85
+ )
86
+
87
+ tool_parameter_description_arguments: Annotated[
88
+ str,
89
+ RJSFMetaTag.StringWidget.textarea(rows=2),
90
+ ] = Field(
91
+ default=DEFAULT_TOOL_PARAMETER_ARGUMENTS_DESCRIPTION,
92
+ description="The description of the 'arguments' parameter.",
93
+ )
94
+
95
+ scope_ids: list[str] = Field(
96
+ default_factory=list,
97
+ title="Scope IDs",
98
+ description=(
99
+ "Knowledge base scope IDs to load skills from. Only the "
100
+ "scopes listed here are queried — sub-folders are not "
101
+ "traversed automatically, so add each scope you want "
102
+ "searched explicitly."
103
+ ),
104
+ )
105
+
106
+ max_listing_desc_chars: int = Field(
107
+ default=MAX_LISTING_DESC_CHARS,
108
+ ge=20,
109
+ le=1000,
110
+ description=(
111
+ "Per-entry hard cap on skill descriptions in the listing. "
112
+ "The listing is for discovery only — the tool loads full "
113
+ "content on invoke."
114
+ ),
115
+ )
116
+
117
+ skill_budget_context_percent: float = Field(
118
+ default=SKILL_BUDGET_CONTEXT_PERCENT,
119
+ ge=0.01,
120
+ le=0.15,
121
+ description="Percentage of context window allocated for the skill listing.",
122
+ )
@@ -0,0 +1,51 @@
1
+ # Example Skill Files
2
+
3
+ These `.md` files are example skills for the SkillTool. To use them:
4
+
5
+ 1. Create one or more **scopes** in the knowledge base (or use existing ones).
6
+ 2. Upload these `.md` files directly into those scopes. Sub-folders are
7
+ not traversed automatically — add each sub-folder's scope ID to
8
+ `scope_ids` if you want its skills loaded.
9
+ 3. Add every scope ID to `skill_tool_config.scope_ids` in the space
10
+ configuration (the field accepts a list — click "Add Item" for each).
11
+ 4. Set `skill_tool_config.enabled` to `true`.
12
+
13
+ The SkillTool fetches all `.md` files from the configured scopes. Only the
14
+ scopes listed in `scope_ids` are queried — sub-folders are not traversed
15
+ automatically, so add each scope you want searched explicitly. Per-user
16
+ folder permissions are enforced by the backend ACL, so users only see
17
+ skills in folders they can access.
18
+
19
+ ## File format
20
+
21
+ Each skill file uses **YAML frontmatter** to declare its metadata:
22
+
23
+ ```markdown
24
+ ---
25
+ name: summarize-document
26
+ description: >-
27
+ Structured document summarization with executive summary.
28
+ Use when the user asks to summarize or get an overview of a document.
29
+ ---
30
+
31
+ # Summarize Document
32
+
33
+ You are an expert document summarizer...
34
+ ```
35
+
36
+ | Frontmatter key | Required | Purpose |
37
+ |-----------------|----------|-------------------------------------------------------------|
38
+ | `name` | Yes | Skill identifier (defaults to file name without `.md`) |
39
+ | `description` | Yes | Short description shown to the LLM in the skill listing — include guidance on when to activate this skill |
40
+
41
+ Everything below the frontmatter is the **skill body** — the full prompt
42
+ instructions injected when the agent invokes the skill.
43
+
44
+ ## Included examples
45
+
46
+ | File | Purpose |
47
+ |---------------------------|--------------------------------------------|
48
+ | `analyze-data.md` | Tabular / numerical data analysis |
49
+ | `analyze-factsheet.md` | Factsheet data analysis |
50
+ | `draft-email.md` | Professional email drafting |
51
+ | `review-contract.md` | Contract risk analysis and review |
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: analyze-data
3
+ description: >-
4
+ Tabular and numerical data analysis with descriptive statistics and insights.
5
+ Use when the user provides data, tables, CSVs, or numbers and wants analysis.
6
+ ---
7
+
8
+ # Analyze Data
9
+
10
+ You are a data analysis expert. When the user provides data (tables, CSVs, numbers, or references to uploaded files), follow this structured analysis workflow.
11
+
12
+ ## Steps
13
+
14
+ 1. **Understand the data**: Identify columns, data types, time ranges, and units.
15
+ 2. **Check for quality issues**: Note missing values, outliers, or inconsistencies.
16
+ 3. **Perform descriptive analysis**:
17
+ - Count, mean, median, min, max for numerical columns.
18
+ - Frequency counts for categorical columns.
19
+ - Time-based trends if dates are present.
20
+ 4. **Identify patterns**: Correlations, groupings, anomalies, or trends.
21
+ 5. **Answer the user's question** using the analysis as evidence.
22
+
23
+ ## Output Format
24
+
25
+ ### Data Overview
26
+ | Property | Value |
27
+ |----------|-------|
28
+ | Rows | ... |
29
+ | Columns | ... |
30
+ | Time range | ... |
31
+
32
+ ### Key Metrics
33
+ Present the most relevant descriptive statistics as a table.
34
+
35
+ ### Insights
36
+ - Numbered list of insights, each backed by a specific data point.
37
+
38
+ ### Recommendations
39
+ Actionable suggestions based on the analysis.
40
+
41
+ ## Rules
42
+
43
+ - Always show your work — include the numbers that support each insight.
44
+ - If the data is ambiguous, state your assumptions explicitly.
45
+ - Use tables for structured output wherever possible.
46
+ - When referencing uploaded files, use `[sourceN]` citations.
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: analyze-factsheet
3
+ description: Financial factsheet analysis with key metrics extraction and investment rationale
4
+ ---
5
+
6
+ # Analyze Financial Factsheet
7
+
8
+ You are a financial analysis specialist. Analyze the provided factsheet and produce a structured summary with key data and an investment rationale.
9
+
10
+ ## Steps
11
+
12
+ 1. Read the full factsheet thoroughly.
13
+ 2. Identify the **fund name**, **asset class**, **currency**, **benchmark**, and **reporting date**.
14
+ 3. Extract key financial metrics (performance, fees, risk indicators, holdings).
15
+ 4. Assess the fund's positioning, strategy, and market context.
16
+ 5. Formulate an investment rationale based on the extracted data.
17
+
18
+ ## Output Format
19
+
20
+ ### Overall Summary
21
+ A concise 3-5 sentence overview covering the fund's objective, strategy, current positioning, and recent performance context.
22
+
23
+ ### Key Data
24
+ | Metric | Value |
25
+ |--------|-------|
26
+ | Fund Name | ... |
27
+ | Asset Class | ... |
28
+ | Currency | ... |
29
+ | Benchmark | ... |
30
+ | Inception Date | ... |
31
+ | Fund Size (AUM) | ... |
32
+ | Ongoing Charges (TER) | ... |
33
+ | YTD Performance | ... |
34
+ | 1Y Performance | ... |
35
+ | 3Y Performance (ann.) | ... |
36
+ | 5Y Performance (ann.) | ... |
37
+ | Volatility | ... |
38
+ | Sharpe Ratio | ... |
39
+ | Max Drawdown | ... |
40
+ | Top Holdings | ... |
41
+ | Sector Allocation | ... |
42
+
43
+ Omit rows where data is not available in the factsheet.
44
+
45
+ ### Investment Rationale
46
+ A structured assessment covering:
47
+ - **Strengths** — what makes this fund attractive (e.g., consistent outperformance, low fees, diversification).
48
+ - **Risks** — potential concerns (e.g., concentration, volatility, sector/geographic exposure).
49
+ - **Suitability** — what type of investor or portfolio this fund fits (e.g., growth-oriented, income-seeking, conservative).
50
+
51
+ ## Rules
52
+
53
+ - This is NOT investment advice — clearly state this disclaimer at the top of your output.
54
+ - Only report data explicitly stated in the factsheet — do not invent or estimate figures.
55
+ - Use `[sourceN]` citations when referencing factsheet content.
56
+ - If performance figures are provided for multiple share classes, note which class is being reported.
57
+ - If the factsheet is not in English, provide the analysis in the factsheet's language.
58
+ - Flag any missing critical data points (e.g., no risk metrics, no benchmark comparison).
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: draft-email
3
+ description: >-
4
+ Professional email drafting with appropriate tone and structure.
5
+ Use when the user wants to write, compose, or draft an email.
6
+ ---
7
+
8
+ # Draft Email
9
+
10
+ You are a professional communication specialist. Help the user draft a clear, well-structured email.
11
+
12
+ ## Steps
13
+
14
+ 1. Ask for or identify from context:
15
+ - **Recipient(s)** and their role/relationship
16
+ - **Purpose** of the email (request, update, follow-up, introduction, etc.)
17
+ - **Key points** to communicate
18
+ - **Tone** (formal, semi-formal, casual)
19
+ 2. Draft the email following the structure below.
20
+ 3. Review for clarity, brevity, and appropriate tone.
21
+
22
+ ## Output Format
23
+
24
+ ```
25
+ Subject: [Clear, specific subject line]
26
+
27
+ [Greeting],
28
+
29
+ [Opening — context or reason for writing, 1-2 sentences]
30
+
31
+ [Body — key information, requests, or updates. Use bullet points for multiple items.]
32
+
33
+ [Closing — next steps, call to action, or sign-off]
34
+
35
+ [Sign-off],
36
+ [Name]
37
+ ```
38
+
39
+ ## Rules
40
+
41
+ - Keep emails concise — aim for under 200 words for routine communication.
42
+ - One email = one topic. Suggest splitting if multiple unrelated topics are detected.
43
+ - Use active voice and direct language.
44
+ - Avoid jargon unless the recipient is a domain expert.
45
+ - If the user provides arguments or context, incorporate it — do not invent details.
46
+ - Suggest a subject line that is specific and actionable (not "Update" or "Quick question").
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: review-contract
3
+ description: >-
4
+ Contract risk analysis, obligation mapping, and clause review.
5
+ Use when the user asks to review, analyze, or check a contract or legal document.
6
+ ---
7
+
8
+ # Review Contract
9
+
10
+ You are a contract review specialist. Analyze the provided contract or legal document and produce a structured review.
11
+
12
+ ## Steps
13
+
14
+ 1. Read the full document thoroughly.
15
+ 2. Identify the **parties**, **effective date**, **term**, and **governing law**.
16
+ 3. Analyze each section for:
17
+ - **Obligations** — what each party must do.
18
+ - **Rights** — what each party is entitled to.
19
+ - **Risks** — clauses that could be unfavorable or ambiguous.
20
+ 4. Flag any missing standard clauses.
21
+ 5. Summarize and provide recommendations.
22
+
23
+ ## Output Format
24
+
25
+ ### Contract Overview
26
+ | Property | Value |
27
+ |----------|-------|
28
+ | Parties | ... |
29
+ | Type | ... |
30
+ | Effective Date | ... |
31
+ | Term | ... |
32
+ | Governing Law | ... |
33
+
34
+ ### Key Obligations
35
+ For each party, list their main obligations as bullet points.
36
+
37
+ ### Risk Assessment
38
+ | Risk | Clause Reference | Severity | Recommendation |
39
+ |------|-----------------|----------|----------------|
40
+ | ... | Section X.Y | High/Med/Low | ... |
41
+
42
+ ### Missing Clauses
43
+ List any standard clauses that are absent (e.g., force majeure, limitation of liability, data protection).
44
+
45
+ ### Recommendations
46
+ Prioritized list of suggested changes or points to negotiate.
47
+
48
+ ## Rules
49
+
50
+ - This is NOT legal advice — clearly state this disclaimer at the top of your output.
51
+ - Reference specific sections and clause numbers from the document.
52
+ - Use `[sourceN]` citations when referencing document content.
53
+ - Flag ambiguous language explicitly.
54
+ - If the document is not in English, provide the review in the document's language.
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ DEFAULT_TOOL_DESCRIPTION = (
4
+ "PRIORITY TOOL — call FIRST before any other tool when a skill matches. "
5
+ "Execute a skill to activate specialized capabilities and domain knowledge. "
6
+ "When a skill matches the user's request, this is a BLOCKING REQUIREMENT: "
7
+ "invoke the Skill tool BEFORE calling any other tool or generating any "
8
+ "response. NEVER mention a skill without actually calling this tool."
9
+ )
10
+
11
+ DEFAULT_TOOL_DESCRIPTION_FOR_SYSTEM_PROMPT = (
12
+ "Execute a skill within the main conversation.\n\n"
13
+ "CRITICAL — Skill tool has HIGHEST PRIORITY among all tools:\n"
14
+ "1. Before you call ANY other tool (InternalSearch, OpenFile, etc.) or "
15
+ "generate ANY response, check if a skill matches the user's request.\n"
16
+ "2. If a skill matches, you MUST invoke the Skill tool FIRST as your "
17
+ "very first action. This is a BLOCKING REQUIREMENT — do not call other "
18
+ "tools or produce text until the skill is loaded.\n"
19
+ "3. NEVER mention a skill without actually calling the tool.\n"
20
+ "4. Do not invoke a skill that is already active in the current turn "
21
+ "(check for <skill_loaded> tags in the conversation).\n"
22
+ "5. After the skill is loaded, follow its instructions directly. The "
23
+ "skill may tell you to call other tools (e.g. InternalSearch) as part "
24
+ "of its workflow — that is expected.\n\n"
25
+ "Important:\n"
26
+ "- Available skills are listed in system-reminder messages in the "
27
+ "conversation. The listing is refreshed every turn — do not rely on a "
28
+ "name unless it appears in the most recent reminder.\n"
29
+ "- The skill description in the listing is a summary. The full "
30
+ "instructions are injected into the conversation only after you invoke "
31
+ "the tool.\n\n"
32
+ "How to invoke:\n"
33
+ '- name: "analyze-data" — invoke a skill by name\n'
34
+ '- name: "summarize", arguments: "focus on key metrics" — invoke with arguments'
35
+ )
36
+
37
+ DEFAULT_TOOL_DESCRIPTION_FOR_USER_PROMPT = ""
38
+
39
+ DEFAULT_TOOL_SYSTEM_REMINDER_FOR_USER_MESSAGE = (
40
+ "<system-reminder>\n"
41
+ "The following skills are available. Use the Skill tool to invoke them.\n"
42
+ "\n"
43
+ "{{ skill_list }}\n"
44
+ "</system-reminder>"
45
+ )
46
+
47
+ DEFAULT_TOOL_PARAMETER_SKILL_NAME_DESCRIPTION = (
48
+ "The name of the skill to invoke. Must be one of the available skills."
49
+ )
50
+
51
+ DEFAULT_TOOL_PARAMETER_ARGUMENTS_DESCRIPTION = (
52
+ "Optional arguments or context to pass to the skill."
53
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ SKILL_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
6
+ SKILL_NAME_MAX_LENGTH = 64
7
+
8
+
9
+ class SkillDefinition(BaseModel):
10
+ """A skill that the agent can activate via the SkillTool.
11
+
12
+ Skills are prompt instruction sets loaded from the knowledge base.
13
+ The listing (name + description) is shown to the LLM for discovery;
14
+ the full ``content`` is only returned when the skill is actually
15
+ invoked.
16
+ """
17
+
18
+ name: str = Field(
19
+ description=(
20
+ "Unique identifier for the skill (used as the tool parameter value). "
21
+ "Must be lowercase kebab-case (a-z, 0-9, hyphens), 1-64 chars."
22
+ ),
23
+ min_length=1,
24
+ max_length=SKILL_NAME_MAX_LENGTH,
25
+ pattern=SKILL_NAME_PATTERN,
26
+ )
27
+ description: str = Field(
28
+ description="Short description shown in the skill listing for the LLM.",
29
+ )
30
+ content: str = Field(
31
+ description="Full prompt / instructions injected when the skill is invoked.",
32
+ )
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import override
4
+
5
+ from jinja2.sandbox import SandboxedEnvironment
6
+ from unique_toolkit.agentic.evaluation.schemas import EvaluationMetricName
7
+ from unique_toolkit.agentic.tools.factory import ToolFactory
8
+ from unique_toolkit.agentic.tools.schemas import ToolCallResponse
9
+ from unique_toolkit.agentic.tools.tool import Tool
10
+ from unique_toolkit.app.schemas import ChatEvent
11
+ from unique_toolkit.chat.schemas import MessageLog, MessageLogStatus
12
+ from unique_toolkit.language_model.schemas import (
13
+ LanguageModelFunction,
14
+ LanguageModelToolDescription,
15
+ )
16
+
17
+ from unique_skill_tool.config import SkillToolConfig
18
+ from unique_skill_tool.schemas import (
19
+ SkillDefinition,
20
+ )
21
+ from unique_skill_tool.utils import (
22
+ format_skill_listing,
23
+ normalize_skill_name,
24
+ )
25
+
26
+
27
+ class SkillTool(Tool[SkillToolConfig]):
28
+ """Tool that lets the agent activate a named skill.
29
+
30
+ The agent calls this with a ``skill_name`` it sees in the skill listing
31
+ (system prompt). The tool looks up the skill in the skill registry
32
+ and returns its full content as the tool response so the agent can
33
+ follow those instructions.
34
+ """
35
+
36
+ name = "Skill"
37
+
38
+ def __init__(
39
+ self,
40
+ event: ChatEvent,
41
+ skill_registry: dict[str, SkillDefinition],
42
+ config: SkillToolConfig,
43
+ ) -> None:
44
+ super().__init__(config, event)
45
+ self._skill_registry = skill_registry
46
+
47
+ @property
48
+ def skill_registry(self) -> dict[str, SkillDefinition]:
49
+ return self._skill_registry
50
+
51
+ @override
52
+ def display_name(self) -> str:
53
+ return "Skill"
54
+
55
+ @override
56
+ def tool_description(self) -> LanguageModelToolDescription:
57
+ skill_names = list(self._skill_registry.keys())
58
+
59
+ skill_name_schema: dict[str, str | list[str]] = {
60
+ "type": "string",
61
+ "description": self.config.tool_parameter_description_skill_name,
62
+ }
63
+ if skill_names:
64
+ skill_name_schema["enum"] = skill_names
65
+
66
+ return LanguageModelToolDescription(
67
+ name=self.name,
68
+ description=self.config.tool_description,
69
+ parameters={
70
+ "type": "object",
71
+ "properties": {
72
+ "skill_name": skill_name_schema,
73
+ "arguments": {
74
+ "type": "string",
75
+ "description": self.config.tool_parameter_description_arguments,
76
+ },
77
+ },
78
+ "required": ["skill_name"],
79
+ },
80
+ )
81
+
82
+ @override
83
+ def tool_description_for_system_prompt(self) -> str:
84
+ """Static instructions for the system prompt.
85
+
86
+ The skill listing is intentionally NOT rendered here. It is
87
+ injected per-turn as a ``<system-reminder>`` block via
88
+ ``SkillTool`` prompt / system-reminder split).
89
+ """
90
+ return self.config.tool_description_for_system_prompt
91
+
92
+ @override
93
+ def tool_description_for_user_prompt(self) -> str:
94
+ return self.config.tool_description_for_user_prompt
95
+
96
+ @override
97
+ def tool_system_reminder_for_user_prompt(self) -> str:
98
+ """Per-turn ``<system-reminder>`` block listing available skills.
99
+
100
+ Renders :attr:`SkillToolConfig.tool_system_reminder_for_user_message` as a
101
+ Jinja template with the budget-aware ``skill_list``. Returned
102
+ verbatim by the orchestrator as a ``{"type": "text"}`` part
103
+ on the latest user message every loop iteration (see
104
+ ``unique_orchestrator._builders.inject_tool_reminders``), so
105
+ the listing cannot go stale. Returns an empty string when the
106
+ skill registry is empty or the reminder template is unset.
107
+ """
108
+ skills = list(self._skill_registry.values())
109
+ if not skills or not self.config.tool_system_reminder_for_user_message:
110
+ return ""
111
+
112
+ listing = format_skill_listing(skills=skills, config=self.config)
113
+ return (
114
+ SandboxedEnvironment()
115
+ .from_string(self.config.tool_system_reminder_for_user_message)
116
+ .render(skill_list=listing)
117
+ )
118
+
119
+ @override
120
+ async def run(self, tool_call: LanguageModelFunction) -> ToolCallResponse:
121
+ args = tool_call.arguments or {}
122
+ raw_skill_name: str = args.get("skill_name", "")
123
+ arguments: str = args.get("arguments", "")
124
+
125
+ if not raw_skill_name or not raw_skill_name.strip():
126
+ return ToolCallResponse(
127
+ id=tool_call.id,
128
+ name=self.name,
129
+ error_message="skill_name must be a non-empty string.",
130
+ )
131
+
132
+ skill_name = normalize_skill_name(raw_skill_name)
133
+ skill = self._skill_registry.get(skill_name)
134
+
135
+ if skill is None:
136
+ available = ", ".join(sorted(self._skill_registry.keys()))
137
+ return ToolCallResponse(
138
+ id=tool_call.id,
139
+ name=self.name,
140
+ error_message=(
141
+ f"Unknown skill: '{skill_name}'. Available skills: {available}"
142
+ ),
143
+ )
144
+
145
+ self._active_message_log = await self._log_skill_loaded(skill_name=skill_name)
146
+
147
+ content_parts = [
148
+ f"<skill_loaded>{skill_name}</skill_loaded>",
149
+ f"Skill '{skill_name}' is now active. Follow the instructions below.",
150
+ "",
151
+ skill.content,
152
+ ]
153
+ if arguments:
154
+ content_parts.append(f"\nUser-provided arguments: {arguments}")
155
+
156
+ return ToolCallResponse(
157
+ id=tool_call.id,
158
+ name=self.name,
159
+ content="\n".join(content_parts),
160
+ system_reminder=(
161
+ f"The skill '{skill_name}' has been loaded. "
162
+ "Follow its instructions now. Do NOT call the Skill tool "
163
+ "again for the same skill in this turn."
164
+ ),
165
+ )
166
+
167
+ async def _log_skill_loaded(self, *, skill_name: str) -> MessageLog | None:
168
+ """Emit a completed message log entry for the loaded skill."""
169
+ progress_message = f"Loaded skill `{skill_name}`"
170
+
171
+ try:
172
+ return await self._message_step_logger.create_or_update_message_log_async(
173
+ active_message_log=None,
174
+ header=progress_message,
175
+ status=MessageLogStatus.COMPLETED,
176
+ )
177
+ except Exception:
178
+ self.logger.debug(
179
+ "SkillTool: failed to write skill-loaded message log",
180
+ exc_info=True,
181
+ )
182
+ return None
183
+
184
+ @override
185
+ def evaluation_check_list(self) -> list[EvaluationMetricName]:
186
+ return []
187
+
188
+ @override
189
+ def get_evaluation_checks_based_on_tool_response(
190
+ self, tool_response: ToolCallResponse
191
+ ) -> list[EvaluationMetricName]:
192
+ return []
193
+
194
+
195
+ ToolFactory.register_tool(SkillTool, SkillToolConfig)
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from unique_skill_tool.config import (
6
+ CHARS_PER_TOKEN,
7
+ DEFAULT_CHAR_BUDGET,
8
+ SkillToolConfig,
9
+ )
10
+ from unique_skill_tool.schemas import (
11
+ SkillDefinition,
12
+ )
13
+
14
+ MIN_DESC_LENGTH = 20
15
+
16
+ _SKILL_PREFIX_TOKEN_RE = re.compile(r"\A\s*/([A-Za-z0-9][A-Za-z0-9_-]*)(?=\s|\Z)")
17
+
18
+
19
+ def get_char_budget(
20
+ context_window_tokens: int | None,
21
+ config: SkillToolConfig,
22
+ ) -> int:
23
+ """Return the character budget for the skill listing.
24
+
25
+ Uses *config.skill_budget_context_percent* of the context window
26
+ (converted to characters via ``CHARS_PER_TOKEN``). Falls back to
27
+ ``DEFAULT_CHAR_BUDGET`` when the context window size is unknown.
28
+ """
29
+ if context_window_tokens is not None:
30
+ return int(
31
+ context_window_tokens
32
+ * CHARS_PER_TOKEN
33
+ * config.skill_budget_context_percent
34
+ )
35
+ return DEFAULT_CHAR_BUDGET
36
+
37
+
38
+ def _get_skill_description(skill: SkillDefinition, max_chars: int) -> str:
39
+ desc = skill.description
40
+ if len(desc) > max_chars:
41
+ return desc[: max_chars - 3] + "..."
42
+ return desc
43
+
44
+
45
+ def _format_entry(skill: SkillDefinition, max_desc_chars: int) -> str:
46
+ return f"- {skill.name}: {_get_skill_description(skill, max_desc_chars)}"
47
+
48
+
49
+ def format_skill_listing(
50
+ skills: list[SkillDefinition],
51
+ context_window_tokens: int | None = None,
52
+ config: SkillToolConfig | None = None,
53
+ ) -> str:
54
+ """Format the skill listing for the system prompt within a character budget.
55
+
56
+ Ported from Claude Code's ``formatCommandsWithinBudget`` (prompt.ts).
57
+
58
+ Strategy:
59
+ 1. Try full descriptions (capped at ``max_listing_desc_chars``).
60
+ 2. If over budget, uniformly truncate descriptions to fit.
61
+ 3. If truncation leaves less than ``MIN_DESC_LENGTH`` per entry, fall
62
+ back to names only.
63
+ """
64
+ if not skills:
65
+ return ""
66
+
67
+ if config is None:
68
+ config = SkillToolConfig()
69
+
70
+ budget = get_char_budget(context_window_tokens, config)
71
+ max_desc = config.max_listing_desc_chars
72
+
73
+ full_entries = [_format_entry(skill, max_desc) for skill in skills]
74
+ full_total = sum(len(e) for e in full_entries) + len(full_entries) - 1
75
+
76
+ if full_total <= budget:
77
+ return "\n".join(full_entries)
78
+
79
+ name_overhead = sum(len(s.name) + 4 for s in skills) + len(skills) - 1
80
+ available_for_descs = budget - name_overhead
81
+ max_desc_len = available_for_descs // len(skills)
82
+
83
+ if max_desc_len < MIN_DESC_LENGTH:
84
+ return "\n".join(f"- {s.name}" for s in skills)
85
+
86
+ return "\n".join(
87
+ f"- {s.name}: {_get_skill_description(s, max_desc_len)}" for s in skills
88
+ )
89
+
90
+
91
+ def normalize_skill_name(skill: str) -> str:
92
+ """Strip whitespace and a leading ``/`` from a skill name."""
93
+ skill = skill.strip()
94
+ if skill.startswith("/"):
95
+ return skill[1:]
96
+ return skill
97
+
98
+
99
+ def extract_prefix_skills(
100
+ user_text: str,
101
+ skill_registry: dict[str, SkillDefinition],
102
+ ) -> tuple[list[SkillDefinition], str]:
103
+ """Pull consecutive ``/skill-name`` tokens from the very start of *user_text*.
104
+
105
+ Matches only tokens at the beginning of the message (after optional
106
+ leading whitespace). Matching stops at the first non-token or unknown
107
+ skill name, so ``/``-prefixed words appearing mid-message (URLs, code,
108
+ prose) are ignored.
109
+
110
+ Duplicates are dropped while preserving first-occurrence order.
111
+
112
+ Returns ``(ordered_skills, remaining_text)`` where *remaining_text* is
113
+ the original message with the matched prefix tokens stripped and
114
+ leading whitespace removed.
115
+ """
116
+ remaining = user_text
117
+ ordered: list[SkillDefinition] = []
118
+ seen: set[str] = set()
119
+
120
+ while True:
121
+ match = _SKILL_PREFIX_TOKEN_RE.match(remaining)
122
+ if match is None:
123
+ break
124
+ name = match.group(1)
125
+ skill = skill_registry.get(name)
126
+ if skill is None:
127
+ break
128
+ if name not in seen:
129
+ seen.add(name)
130
+ ordered.append(skill)
131
+ remaining = remaining[match.end() :]
132
+
133
+ return ordered, remaining.lstrip()