"""
PluginDataSkill model for storing Agent Skill (SKILL.md) plugin data.
.. note::
**Experimental.** The SkillPlugin was designed and coded by Claude Code (Anthropic's
Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will
be documented.
"""
import posixpath
from typing import Any, Optional, Union
from django.db import models
from smarter.apps.plugin.manifest.models.skill_plugin.document import (
SkillDocument,
SkillDocumentError,
normalize_resource_path,
normalize_resources,
parse_skill_document,
)
from smarter.common.exceptions import SmarterValueError
from smarter.lib import json, logging
from smarter.lib.cache import cache_results
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.typing import SmarterDictType
from .plugin_data_base import PluginDataBase
from .plugin_meta import PluginMeta
logger = logging.getSmarterLogger(__name__, any_switches=[SmarterWaffleSwitches.PLUGIN_LOGGING])
logger_prefix = logging.formatted_text(f"{__name__}")
[docs]
class PluginDataSkill(PluginDataBase):
"""
Stores an Agent Skill for a Smarter plugin, per the SKILL.md standard.
An Agent Skill is a ``SKILL.md`` document -- YAML frontmatter (``name``, ``description``,
and optionally ``license``, ``compatibility``, ``metadata`` and ``allowed-tools``) followed
by Markdown instructions -- plus any bundled files (``scripts/``, ``references/``,
``assets/``) that the instructions refer to by relative path.
The verbatim ``SKILL.md`` text is stored in ``skill_document``. The parsed frontmatter and
the normalized ``allowed-tools`` are derived from it on every save. Bundled files are stored
in ``resources``, keyed by path relative to the skill root.
Skills that were retrieved from a remote source, such as a GitHub repository, record
the source URL and retrieval time. Their ``skill_document`` and ``resources`` are a
snapshot of the remote skill, which is refreshed when the plugin manifest is applied.
``PluginDataSkill`` is a concrete subclass of :class:`PluginDataBase`, and is referenced by
:class:`PluginMeta` to provide the data payload for skill-type plugins.
.. seealso::
- :class:`PluginDataBase`
- :class:`PluginMeta`
- :py:mod:`smarter.apps.plugin.manifest.models.skill_plugin.document`
- Agent Skills specification: https://agentskills.io/specification
"""
# pylint: disable=C0115
class Meta:
verbose_name = "Plugin Skill Data"
verbose_name_plural = "Plugin Skill Data"
skill_document = models.TextField(
help_text=(
"The raw contents of the SKILL.md file for this plugin: a YAML frontmatter "
"block (name, description, and optionally license and allowed-tools) followed "
"by a Markdown body containing the instructions returned to the LLM when this "
"plugin is invoked by the user prompt."
),
)
"""The verbatim SKILL.md document (frontmatter + Markdown body)."""
metadata = models.JSONField(
help_text="Parsed YAML frontmatter from skill_document (name, description, license, allowed-tools, and any additional custom keys).",
default=dict,
encoder=json.SmarterJSONEncoder,
blank=True,
)
"""The complete parsed frontmatter of ``skill_document``.
Derived on every save().
"""
allowed_tools = models.JSONField(
help_text="Optional list of tool names this skill is permitted to invoke, mirroring the SKILL.md 'allowed-tools' frontmatter key.",
default=list,
encoder=json.SmarterJSONEncoder,
blank=True,
)
"""The frontmatter's ``allowed-tools``, normalized to a list.
Derived on every save().
"""
resources = models.JSONField(
help_text=(
"The skill's bundled files (e.g. scripts/, references/, assets/), keyed by path relative "
"to the skill root. A null value denotes a file whose contents are unavailable, such as a binary asset."
),
default=dict,
encoder=json.SmarterJSONEncoder,
blank=True,
)
"""The skill's bundled files, keyed by normalized path relative to the skill root."""
source_url = models.URLField(
max_length=2048,
help_text="The URL from which this skill was retrieved, if it was sourced remotely, e.g. from a GitHub repository.",
blank=True,
null=True,
)
"""The skill source URL, as written in the plugin manifest, for remotely sourced skills."""
source_retrieved_at = models.DateTimeField(
help_text="When this skill was last retrieved from its source_url.",
blank=True,
null=True,
)
"""When a remotely sourced skill was last retrieved."""
@property
def document(self) -> SkillDocument:
"""
The parsed SKILL.md document.
:raises SkillDocumentError: If ``skill_document`` is invalid.
"""
return parse_skill_document(self.skill_document)
@property
def instructions(self) -> str:
"""The Markdown instructions of the skill: the body of SKILL.md."""
return self.document.body
@property
def resource_paths(self) -> list[str]:
"""The paths of the skill's bundled files, relative to the skill root."""
return list(self.resources or {})
@property
def return_data_keys(self) -> list[str]:
"""
Return the paths of the skill's bundled files, which the LLM can request by name.
:return: The paths of the bundled files, e.g. ``['references/FORMS.md', 'scripts/fill_form.py']``.
:rtype: list[str]
"""
return self.resource_paths
[docs]
def find_resource(self, path: str) -> tuple[str, Optional[str]]:
"""
Find a bundled file by the path that the LLM requested.
The LLM may refer to a file exactly as SKILL.md does, which is not always its actual
path. For example, Anthropic's pdf skill refers to ``FORMS.md``, which is bundled as
``forms.md``. So, in order of precedence, this matches the normalized path, then the
path ignoring case, then a unique file name ignoring case.
:param path: The requested path, relative to the skill root.
:return: A tuple of the file's path and its contents. The contents are None if unavailable.
:raises SmarterValueError: If no bundled file, or more than one, matches the path.
**Example:**
.. code-block:: python
plugin_data.find_resource("./FORMS.md")
# ('forms.md', '# PDF form filling guide ...')
"""
resources = self.resources or {}
try:
normalized = normalize_resource_path(path)
except SkillDocumentError as e:
raise SmarterValueError(str(e)) from e
if normalized in resources:
return normalized, resources[normalized]
matches = [key for key in resources if key.lower() == normalized.lower()]
if not matches:
basename = posixpath.basename(normalized).lower()
matches = [key for key in resources if posixpath.basename(key).lower() == basename]
if len(matches) == 1:
return matches[0], resources[matches[0]]
if matches:
raise SmarterValueError(f"resource {path} is ambiguous. It could refer to any of: {sorted(matches)}")
raise SmarterValueError(f"resource {path} was not found. Available resources are: {self.resource_paths}")
[docs]
def validate(self) -> bool:
"""
Validate the skill document and bundled files.
:raises SmarterValueError: If ``skill_document`` is not a valid SKILL.md document, or a
bundled file path or its contents are invalid.
"""
super().validate()
parse_skill_document(self.skill_document)
normalize_resources(self.resources)
return True
[docs]
def save(self, *args, **kwargs):
"""
Validate the skill, derive the frontmatter fields from ``skill_document``, and save.
The tool description presented to the LLM defaults to the skill's ``description``,
which, per the specification, describes what the skill does and when to use it.
"""
document = parse_skill_document(self.skill_document)
self.skill_document = document.document
self.metadata = document.frontmatter
self.allowed_tools = document.allowed_tools
self.resources = normalize_resources(self.resources)
if not self.description:
self.description = document.description
super().save(*args, **kwargs)
[docs]
def sanitized_return_data(self, params: Optional[SmarterDictType] = None) -> SmarterDictType:
"""
Return the skill, or one of its bundled files, for the LLM.
This implements the progressive disclosure of the Agent Skills specification. The
skill's name and description are presented to the LLM as the tool description. When
the LLM invokes the tool, it receives the skill's instructions, along with the paths
of its bundled files. The LLM then invokes the tool again, with ``resource`` set to a
path, to read a bundled file only when the instructions call for it.
:param params: The tool call arguments. If ``resource`` is present, the bundled file
at that path is returned instead of the instructions.
:type params: Optional[dict]
:return: The skill instructions, or the bundled file.
:rtype: SmarterDictType
:raises SmarterValueError: If the requested resource does not exist.
**Example:**
.. code-block:: python
plugin_data.sanitized_return_data()
# {'name': 'pdf', 'description': '...', 'instructions': '# PDF processing ...', 'resources': ['forms.md', ...], ...}
plugin_data.sanitized_return_data({"resource": "forms.md"})
# {'name': 'pdf', 'resource': 'forms.md', 'content': '# PDF form filling ...'}
"""
document = self.document
requested = (params or {}).get("resource")
if requested:
path, content = self.find_resource(requested)
retval: SmarterDictType = {"name": document.name, "resource": path, "content": content}
if content is None:
retval["note"] = "The contents of this file are not available, because it is not a text file."
return retval
retval = {
"name": document.name,
"description": document.description,
"instructions": document.body,
}
for key, value in (
("license", document.license),
("compatibility", document.compatibility),
("allowed_tools", document.allowed_tools),
("metadata", document.metadata),
):
if value:
retval[key] = value
if self.resource_paths:
retval["resources"] = self.resource_paths
retval["note"] = (
"The instructions may refer to the bundled files listed in 'resources'. To read one, call this "
"tool again with 'resource' set to its path. Scripts are provided for reference, and cannot be "
"executed by this tool."
)
return retval
[docs]
def data(self, params: Optional[SmarterDictType] = None) -> Optional[SmarterDictType]:
"""
Return the skill as a structured dictionary of frontmatter, instructions and bundled files.
:param params: Optional parameters for future extensibility (currently unused).
:type params: Optional[dict]
:return: A dict with ``frontmatter``, ``instructions`` and ``resources`` keys, or None if
``skill_document`` is invalid.
:rtype: Optional[dict]
"""
try:
document = self.document
except SkillDocumentError as e:
logger.error("%s.data: Failed to parse skill_document: %s", self.formatted_class_name, e)
return None
return {"frontmatter": document.frontmatter, "instructions": document.body, "resources": self.resources}
[docs]
def manifest_data(self) -> SmarterDictType:
"""
Return the ``spec.skillData`` section of the plugin manifest, as the author wrote it.
:return: ``{"source": {"url": ...}}`` for a remotely sourced skill, otherwise
``{"skill": ..., "resources": ...}``.
:rtype: SmarterDictType
"""
if self.source_url:
return {"source": {"url": self.source_url}}
return {"skill": self.skill_document, "resources": self.resources or None}
[docs]
@classmethod
def get_cached_data_by_plugin(cls, plugin: PluginMeta, invalidate: bool = False) -> Union["PluginDataSkill", None]:
"""
Return a single instance of PluginDataSkill by plugin.
This method caches the results to improve performance.
:param plugin: The plugin whose data should be retrieved.
:type plugin: PluginMeta
:return: A PluginDataSkill instance if found, otherwise None.
:rtype: Union[PluginDataSkill, None]
"""
@cache_results()
def data_by_plugin_id(plugin_id: int) -> Union["PluginDataSkill", None]:
try:
retval = cls.objects.prefetch_related("plugin").get(plugin_id=plugin_id)
logger.debug(
"%s.get_cached_data_by_plugin() fetched and cached PluginDataSkill for plugin_id: %s",
logging.formatted_text(cls.__name__),
plugin_id,
)
return retval
except cls.DoesNotExist as e:
logger.warning(
"%s.get_cached_data_by_plugin() - Data not found for plugin_id: %s",
logging.formatted_text(cls.__name__),
plugin_id,
)
raise cls.DoesNotExist(f"PluginDataSkill with plugin_id {plugin_id} does not exist.") from e
if invalidate:
data_by_plugin_id.invalidate(plugin.id) # type: ignore[union-attr]
return data_by_plugin_id(plugin.id) # type: ignore[return-value]
# pylint: disable=W0221
[docs]
@classmethod
def get_cached_object(
cls,
*args,
invalidate: Optional[bool] = False,
pk: Optional[int] = None,
plugin: Optional[PluginMeta] = None,
**kwargs,
) -> Optional["PluginDataBase"]:
"""
Retrieve a model instance by primary key, using caching to optimize performance.
This method is selectively overridden in models that inherit from MetaDataModel to provide class-specific
function parameters.
Example usage:
.. code-block:: python
# Retrieve by primary key
instance = MyModel.get_cached_object(pk=1)
:param invalidate: If True, invalidate the cache for this query before retrieving the object.
:type invalidate: bool
:param pk: The primary key of the model instance to retrieve.
:type pk: int
:param plugin: The PluginMeta instance associated with the data to retrieve.
:type plugin: PluginMeta
:returns: The model instance if found, otherwise None.
:rtype: Optional["PluginDataBase"]
"""
# pylint: disable=W0621
logger_prefix = logging.formatted_text(f"{__name__}.{PluginDataSkill.__name__}.get_cached_object()")
logger.debug(
"%s called with pk: %s, plugin: %s",
logger_prefix,
pk,
plugin,
)
@cache_results()
def _get_model_by_plugin_meta(plugin_id: int) -> Optional["PluginDataBase"]:
try:
logger.debug(
"%s._get_model_by_plugin_meta() cache miss for plugin_id: %s",
logger_prefix,
plugin_id,
)
retval = cls.objects.prefetch_related("plugin").get(plugin_id=plugin_id)
logger.debug(
"%s._get_model_by_plugin_meta() fetched and cached PluginDataSkill for plugin_id: %s",
logger_prefix,
plugin_id,
)
return retval
except cls.DoesNotExist as e:
logger.warning(
"%s.get_cached_data_by_plugin() - Data not found for plugin_id: %s",
cls.formatted_class_name,
plugin_id,
)
raise cls.DoesNotExist(f"PluginDataSkill with plugin_id {plugin_id} does not exist.") from e
if invalidate and plugin:
_get_model_by_plugin_meta.invalidate(plugin.id) # type: ignore[union-attr]
retval: "PluginDataSkill"
if pk:
retval = super().get_cached_object(*args, invalidate=invalidate, pk=pk, **kwargs) # type: ignore[return-value]
if plugin:
retval = _get_model_by_plugin_meta(plugin.id) # type: ignore[return-value]
return retval