Python: Add ClassSkill for class-based skill definitions (#5678)

* Python: Add ClassSkill for class-based skill definitions

Add ClassSkill abstract base class with decorator-based resource and script
discovery, porting .NET's AgentClassSkill (PRs #5027 and #5183) to Python.

- Add ClassSkill(Skill, ABC) with instructions abstract property, cached
  content/resources/scripts properties
- Add @ClassSkill.resource and @ClassSkill.script static method decorators
  for auto-discovery of methods and properties
- Extract _build_skill_content() and _create_resource_element() shared
  helpers from InlineSkill for reuse
- Add _discover_marked_members() for scanning class hierarchies
- Add _make_method_name() for Python-to-skill name conversion
- Add class_based_skill sample (UnitConverterSkill)
- Update mixed_skills sample with TemperatureConverterSkill
- Add 58 new tests covering ClassSkill, decorator discovery, property
  resources, inheritance, kwargs forwarding, and duplicate detection
- Export ClassSkill from agent_framework public API

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: replace try/except/continue with assignment to satisfy bandit B112

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* address PR review feedback

- Walk cls.__mro__ in _discover_marked_members for inherited property resources
- Use inspect.getattr_static for MRO-aware is_property check
- Return defensive copies from resources/scripts properties
- Raise TypeError on wrong decorator stacking order (@resource above @property)
- Log warning instead of silently swallowing descriptor errors during discovery
- Validate explicit name= at decoration time via _validate_member_name
- Add tests for all of the above

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix temperature converter skill: make resource necessary for script

Refactor TemperatureConverterSkill so the agent must read the
formulas resource (factor/offset) before calling the script,
aligning with the volume-converter pattern.

- Resource: numeric factor/offset table instead of symbolic formulas
- Script: generic linear transform (value * factor + offset)
- Instructions: updated to reflect new workflow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2026-05-07 20:39:12 +01:00
committed by GitHub
Unverified
parent a478d1b53c
commit 1d94518f37
8 changed files with 1583 additions and 84 deletions
@@ -135,6 +135,7 @@ from ._sessions import (
from ._settings import SecretString, load_settings
from ._skills import (
AggregatingSkillsSource,
ClassSkill,
DeduplicatingSkillsSource,
DelegatingSkillsSource,
FileSkill,
@@ -345,6 +346,7 @@ __all__ = [
"ChatResponseUpdate",
"CheckResult",
"CheckpointStorage",
"ClassSkill",
"CompactionProvider",
"CompactionStrategy",
"Content",
+484 -41
View File
@@ -5,7 +5,7 @@
Defines the core data model classes for the agent skills system:
- **Skills:** :class:`Skill` (abstract base), :class:`InlineSkill` (code-defined),
and :class:`FileSkill` (filesystem-backed).
:class:`ClassSkill` (class-based), and :class:`FileSkill` (filesystem-backed).
- **Resources:** :class:`SkillResource` (abstract base), :class:`InlineSkillResource`
(static content or callable).
- **Scripts:** :class:`SkillScript` (abstract base), :class:`InlineSkillScript`
@@ -27,6 +27,9 @@ Skills can come from different sources:
Represented as :class:`FileSkill` instances.
- **Code-defined** — created as :class:`InlineSkill` instances in Python code,
with optional callable resources attached via the ``@skill.resource`` decorator.
- **Class-based** — created by subclassing :class:`ClassSkill` to define
self-contained, reusable skill types with ``create_resource()`` and
``create_script()`` factory methods.
- **Custom sources** — any :class:`SkillsSource` implementation that provides
skills from arbitrary origins (REST APIs, databases, etc.).
@@ -570,6 +573,65 @@ def _validate_skill_description(name: str, description: str) -> None:
)
def _build_skill_content(
name: str,
description: str,
instructions: str,
resources: Sequence[SkillResource] | None = None,
scripts: Sequence[SkillScript] | None = None,
) -> str:
"""Build XML-structured content for code-defined and class-based skills.
Produces an XML document containing name, description, instructions,
resources, and scripts elements. Used by both :class:`InlineSkill`
and :class:`ClassSkill` to generate their ``content`` property.
Args:
name: The skill name.
description: The skill description.
instructions: The raw instructions text.
resources: Optional resources associated with the skill.
scripts: Optional scripts associated with the skill.
Returns:
An XML-structured content string.
"""
result = (
f"<name>{xml_escape(name)}</name>\n"
f"<description>{xml_escape(description)}</description>\n"
"\n"
"<instructions>\n"
f"{instructions}\n"
"</instructions>"
)
if resources:
resource_lines = "\n".join(_create_resource_element(r) for r in resources)
result += f"\n\n<resources>\n{resource_lines}\n</resources>"
if scripts:
script_lines = "\n".join(_create_script_element(s) for s in scripts)
result += f"\n\n<scripts>\n{script_lines}\n</scripts>"
return result
def _create_resource_element(resource: SkillResource) -> str:
"""Create a self-closing ``<resource …/>`` XML element from a :class:`SkillResource`.
Args:
resource: The resource to create the element from.
Returns:
A single indented XML element string with ``name`` and optional
``description`` attributes.
"""
attrs = f'name="{xml_escape(resource.name, quote=True)}"'
if resource.description:
attrs += f' description="{xml_escape(resource.description, quote=True)}"'
return f" <resource {attrs}/>"
@experimental(feature_id=ExperimentalFeature.SKILLS)
class InlineSkill(Skill):
"""A skill defined entirely in code with resources and scripts.
@@ -634,25 +696,10 @@ class InlineSkill(Skill):
if self._cached_content is not None:
return self._cached_content
result = (
f"<name>{xml_escape(self.name)}</name>\n"
f"<description>{xml_escape(self.description)}</description>\n"
"\n"
"<instructions>\n"
f"{self.instructions}\n"
"</instructions>"
self._cached_content = _build_skill_content(
self.name, self.description, self.instructions, self._resources, self._scripts
)
if self._resources:
resource_lines = "\n".join(self._create_resource_element(r) for r in self._resources)
result += f"\n\n<resources>\n{resource_lines}\n</resources>"
if self._scripts:
script_lines = "\n".join(_create_script_element(s) for s in self._scripts)
result += f"\n\n<scripts>\n{script_lines}\n</scripts>"
self._cached_content = result
return result
return self._cached_content
@property
def resources(self) -> list[SkillResource]:
@@ -664,22 +711,6 @@ class InlineSkill(Skill):
"""Mutable list of :class:`SkillScript` instances."""
return self._scripts
@staticmethod
def _create_resource_element(resource: SkillResource) -> str:
"""Create a self-closing ``<resource …/>`` XML element from an :class:`SkillResource`.
Args:
resource: The resource to create the element from.
Returns:
A single indented XML element string with ``name`` and optional
``description`` attributes.
"""
attrs = f'name="{xml_escape(resource.name, quote=True)}"'
if resource.description:
attrs += f' description="{xml_escape(resource.description, quote=True)}"'
return f" <resource {attrs}/>"
def resource(
self,
func: Callable[..., Any] | None = None,
@@ -700,8 +731,7 @@ class InlineSkill(Skill):
Keyword Args:
name: Resource name override. Defaults to ``func.__name__``.
description: Resource description override. Defaults to the
function's docstring (via :func:`inspect.getdoc`).
description: Resource description override. Defaults to ``None``.
Returns:
The original function unchanged, or a secondary decorator when
@@ -727,7 +757,7 @@ class InlineSkill(Skill):
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
resource_name = name or f.__name__
resource_description = description or (inspect.getdoc(f) or None)
resource_description = description
self._resources.append(
InlineSkillResource(
name=resource_name,
@@ -761,8 +791,7 @@ class InlineSkill(Skill):
Keyword Args:
name: Script name override. Defaults to ``func.__name__``.
description: Script description override. Defaults to the
function's docstring (via :func:`inspect.getdoc`).
description: Script description override. Defaults to ``None``.
Returns:
The original function unchanged, or a secondary decorator when
@@ -789,7 +818,7 @@ class InlineSkill(Skill):
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
script_name = name or f.__name__
script_description = description or (inspect.getdoc(f) or None)
script_description = description
self._scripts.append(
InlineSkillScript(
name=script_name,
@@ -804,6 +833,420 @@ class InlineSkill(Skill):
return decorator(func)
def _make_method_name(method_name: str) -> str:
"""Convert a Python method name to a skill resource/script name.
Replaces underscores with hyphens to match the skill naming convention.
Args:
method_name: The Python method name (e.g. ``"conversion_table"``).
Returns:
The converted name (e.g. ``"conversion-table"``).
"""
return method_name.replace("_", "-").strip("-")
def _validate_member_name(name: str, kind: str) -> None:
"""Validate a resource or script name at decoration time.
Args:
name: The name to validate.
kind: ``"resource"`` or ``"script"`` — used in error messages.
Raises:
ValueError: If the name is empty, too long, or contains invalid characters.
"""
if not name or not name.strip():
raise ValueError(f"@ClassSkill.{kind} name cannot be empty.")
if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name):
raise ValueError(
f"Invalid @ClassSkill.{kind} name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, "
"using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen "
"or contain consecutive hyphens."
)
def _discover_marked_members(cls: type, marker_attr: str) -> list[tuple[str, dict[str, Any]]]:
"""Scan a class for methods or properties stamped with a marker attribute.
Checks both regular callable attributes (via ``dir``) and ``property``
descriptors (via ``cls.__dict__``) whose ``fget`` carries the marker.
Args:
cls: The class to scan.
marker_attr: The marker attribute name to look for (e.g.
``"_skill_resource_marker"``).
Returns:
A list of ``(member_name, marker_dict)`` tuples.
"""
results: list[tuple[str, dict[str, Any]]] = []
seen: set[str] = set()
# Walk the MRO so that property-resources defined on a parent class
# are also discovered. ``cls.__dict__`` only sees the leaf class.
for klass in cls.__mro__:
for attr_name, attr_value in klass.__dict__.items():
if attr_name in seen:
continue
if (
isinstance(attr_value, property)
and attr_value.fget is not None
and hasattr(attr_value.fget, marker_attr)
):
results.append((attr_name, getattr(attr_value.fget, marker_attr)))
seen.add(attr_name)
# Check regular callable attributes.
for attr_name in dir(cls):
if attr_name in seen:
continue
try:
attr = getattr(cls, attr_name, None)
except Exception:
# Some descriptors (e.g. abstract properties) may raise on access.
logger.warning("Skipping '%s' during skill discovery: descriptor raised on access", attr_name)
attr = None
if attr is not None and callable(attr) and hasattr(attr, marker_attr):
results.append((attr_name, getattr(attr, marker_attr)))
return results
@experimental(feature_id=ExperimentalFeature.SKILLS)
class ClassSkill(Skill, ABC):
"""Abstract base class for defining skills as reusable Python classes.
Inherit from this class to create a self-contained skill definition.
Override :attr:`instructions` to provide the skill body.
Resources and scripts can be defined in two ways:
- **Decorator-based (recommended):** Mark methods with
:meth:`ClassSkill.resource` and :meth:`ClassSkill.script` decorators
for automatic discovery.
- **Explicit override:** Override the :attr:`resources` and
:attr:`scripts` properties, constructing :class:`InlineSkillResource`
and :class:`InlineSkillScript` instances directly.
Class-based skills can be distributed via shared libraries or PyPI
packages, making them easy to reuse across projects.
Attributes:
name: Skill name (lowercase letters, numbers, hyphens only).
description: Human-readable description of the skill.
Examples:
Decorator-based (recommended):
.. code-block:: python
class UnitConverterSkill(ClassSkill):
def __init__(self) -> None:
super().__init__(
name="unit-converter",
description="Convert between common units.",
)
@property
def instructions(self) -> str:
return "Use this skill to convert units..."
@ClassSkill.resource(name="table")
def conversion_table(self) -> str:
return "| From | To | Factor |..."
@ClassSkill.script(name="convert")
def convert(self, value: float, factor: float) -> str:
return json.dumps({"result": round(value * factor, 4)})
Explicit override:
.. code-block:: python
class UnitConverterSkill(ClassSkill):
def __init__(self) -> None:
super().__init__(
name="unit-converter",
description="Convert between common units.",
)
@property
def instructions(self) -> str:
return "Use this skill to convert units..."
@property
def resources(self) -> list[SkillResource]:
return [
InlineSkillResource(name="table", content="| From | To | Factor |..."),
]
@property
def scripts(self) -> list[SkillScript]:
return [InlineSkillScript(name="convert", function=convert_fn)]
"""
def __init__(
self,
*,
name: str,
description: str,
) -> None:
"""Initialize a ClassSkill.
Args:
name: Skill name (lowercase letters, numbers, hyphens only;
max 64 characters).
description: Human-readable description of the skill
(≤1024 characters).
"""
super().__init__(name=name, description=description)
self._cached_content: str | None = None
self._cached_resources: list[SkillResource] | None = None
self._cached_scripts: list[SkillScript] | None = None
@staticmethod
def resource(
func: Callable[..., Any] | None = None,
*,
name: str | None = None,
description: str | None = None,
) -> Any:
"""Decorator that marks a method or property as a skill resource for auto-discovery.
When applied to a method or property on a :class:`ClassSkill` subclass,
it is automatically discovered and registered as an
:class:`InlineSkillResource`. Methods are invoked each time the
resource is read. Properties are evaluated via their getter.
Can be applied to a method directly, or stacked with ``@property``
(place ``@property`` first, ``@ClassSkill.resource`` second).
Supports bare usage (``@ClassSkill.resource``) and parameterized usage
(``@ClassSkill.resource(name="custom", description="...")``).
Args:
func: The function being decorated. Populated automatically when
the decorator is applied without parentheses.
Keyword Args:
name: Resource name override. Defaults to the method name with
underscores replaced by hyphens.
description: Resource description. Defaults to ``None``.
Examples:
On a method:
.. code-block:: python
@ClassSkill.resource(name="conversion-table")
def get_table(self) -> str:
return "..."
On a property:
.. code-block:: python
@property
@ClassSkill.resource
def conversion_table(self) -> str:
return "..."
"""
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
if isinstance(f, (property, classmethod, staticmethod)):
raise TypeError(
"@ClassSkill.resource must be applied before @property, @classmethod, or @staticmethod. "
"Place @property first, then @ClassSkill.resource."
)
if name is not None:
_validate_member_name(name, "resource")
f._skill_resource_marker = { # type: ignore[attr-defined]
"name": name,
"description": description,
}
return f
if func is None:
return decorator
return decorator(func)
@staticmethod
def script(
func: Callable[..., Any] | None = None,
*,
name: str | None = None,
description: str | None = None,
) -> Any:
"""Decorator that marks a method as a skill script for auto-discovery.
When applied to a method on a :class:`ClassSkill` subclass, the method is
automatically discovered and registered as an :class:`InlineSkillScript`.
The method's parameters (excluding ``self``) are used to generate a JSON
schema, and the method is invoked in-process when the script is run.
Supports bare usage (``@ClassSkill.script``) and parameterized usage
(``@ClassSkill.script(name="custom", description="...")``).
Args:
func: The function being decorated. Populated automatically when
the decorator is applied without parentheses.
Keyword Args:
name: Script name override. Defaults to the method name with
underscores replaced by hyphens.
description: Script description. Defaults to ``None``.
Examples:
.. code-block:: python
@ClassSkill.script(name="convert")
def convert(self, value: float, factor: float) -> str:
return json.dumps({"result": round(value * factor, 4)})
"""
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
if isinstance(f, (property, classmethod, staticmethod)):
raise TypeError(
"@ClassSkill.script must be applied before @property, @classmethod, or @staticmethod."
)
if name is not None:
_validate_member_name(name, "script")
f._skill_script_marker = { # type: ignore[attr-defined]
"name": name,
"description": description,
}
return f
if func is None:
return decorator
return decorator(func)
@property
@abstractmethod
def instructions(self) -> str:
"""The raw instructions text for this skill.
Subclasses must override this property to provide the skill body.
"""
...
@property
def resources(self) -> list[SkillResource]:
"""Resources discovered from :meth:`ClassSkill.resource`-decorated methods.
On first access, scans the class for methods marked with the
:meth:`ClassSkill.resource` decorator and instantiates
:class:`InlineSkillResource` instances from them.
The result is cached after the first access.
Override this property to provide resources explicitly instead of
using decorator-based discovery.
"""
if self._cached_resources is not None:
return list(self._cached_resources)
resources: list[SkillResource] = []
seen_names: set[str] = set()
for attr_name, attr in _discover_marked_members(type(self), "_skill_resource_marker"):
marker: dict[str, Any] = attr
resource_name = marker.get("name") or _make_method_name(attr_name)
if resource_name in seen_names:
raise ValueError(
f"Skill '{self.name}' already has a resource named '{resource_name}'. "
"Ensure each @ClassSkill.resource has a unique name."
)
seen_names.add(resource_name)
# Use inspect.getattr_static to check the descriptor type without
# triggering it, and walk the MRO so inherited properties are found.
static_attr = inspect.getattr_static(self, attr_name, None)
is_property = isinstance(static_attr, property)
resource_description = marker.get("description")
if is_property:
# Property — use a lambda that reads the property value each time.
# We capture attr_name to avoid late-binding issues.
# Do NOT call getattr here to avoid triggering the getter during discovery.
resource_func = (lambda name: lambda: getattr(self, name))(attr_name)
resources.append(
InlineSkillResource(
name=resource_name,
function=resource_func,
description=resource_description,
)
)
else:
# Regular method — use the bound method directly.
bound_method = getattr(self, attr_name)
resources.append(
InlineSkillResource(
name=resource_name,
function=bound_method,
description=resource_description,
)
)
self._cached_resources = resources
return list(self._cached_resources)
@property
def scripts(self) -> list[SkillScript]:
"""Scripts discovered from :meth:`ClassSkill.script`-decorated methods.
On first access, scans the class for methods marked with the
:meth:`ClassSkill.script` decorator and instantiates
:class:`InlineSkillScript` instances from them.
The result is cached after the first access.
Override this property to provide scripts explicitly instead of
using decorator-based discovery.
"""
if self._cached_scripts is not None:
return list(self._cached_scripts)
scripts: list[SkillScript] = []
seen_names: set[str] = set()
for attr_name, attr in _discover_marked_members(type(self), "_skill_script_marker"):
marker: dict[str, Any] = attr
script_name = marker.get("name") or _make_method_name(attr_name)
if script_name in seen_names:
raise ValueError(
f"Skill '{self.name}' already has a script named '{script_name}'. "
"Ensure each @ClassSkill.script has a unique name."
)
seen_names.add(script_name)
bound_method = getattr(self, attr_name)
script_description = marker.get("description")
scripts.append(
InlineSkillScript(
name=script_name,
function=bound_method,
description=script_description,
)
)
self._cached_scripts = scripts
return list(self._cached_scripts)
@property
def content(self) -> str:
"""Synthesized XML content containing name, description, instructions, resources, and scripts.
The result is cached after the first access.
"""
if self._cached_content is not None:
return self._cached_content
self._cached_content = _build_skill_content(
self.name, self.description, self.instructions, self.resources, self.scripts
)
return self._cached_content
@experimental(feature_id=ExperimentalFeature.SKILLS)
class FileSkill(Skill):
"""A :class:`Skill` discovered from a filesystem directory backed by a SKILL.md file.
+762 -8
View File
@@ -5,6 +5,7 @@
from __future__ import annotations
import os
from abc import ABC
from collections.abc import Sequence
from pathlib import Path
from typing import Any
@@ -14,6 +15,7 @@ import pytest
from agent_framework import (
AggregatingSkillsSource,
ClassSkill,
DeduplicatingSkillsSource,
FileSkill,
FileSkillScript,
@@ -32,6 +34,7 @@ from agent_framework._skills import (
DEFAULT_SCRIPT_EXTENSIONS,
InlineSkillResource,
InlineSkillScript,
_create_resource_element,
_create_script_element,
_FileSkillResource,
)
@@ -1004,7 +1007,7 @@ class TestInlineSkill:
assert len(skill.resources) == 1
assert skill.resources[0].name == "get_schema"
assert skill.resources[0].description == "Get the database schema."
assert skill.resources[0].description is None
assert isinstance(skill.resources[0], InlineSkillResource)
assert skill.resources[0].function is get_schema
@@ -1677,22 +1680,22 @@ class TestCreateResourceElement:
def test_name_only(self) -> None:
r = InlineSkillResource(name="my-ref", content="data")
elem = InlineSkill._create_resource_element(r)
elem = _create_resource_element(r)
assert elem == ' <resource name="my-ref"/>'
def test_with_description(self) -> None:
r = InlineSkillResource(name="my-ref", description="A reference.", content="data")
elem = InlineSkill._create_resource_element(r)
elem = _create_resource_element(r)
assert elem == ' <resource name="my-ref" description="A reference."/>'
def test_xml_escapes_name(self) -> None:
r = InlineSkillResource(name='ref"special', content="data")
elem = InlineSkill._create_resource_element(r)
elem = _create_resource_element(r)
assert "&quot;" in elem
def test_xml_escapes_description(self) -> None:
r = InlineSkillResource(name="ref", description='Uses <tags> & "quotes"', content="data")
elem = InlineSkill._create_resource_element(r)
elem = _create_resource_element(r)
assert "&lt;tags&gt;" in elem
assert "&amp;" in elem
assert "&quot;" in elem
@@ -2136,8 +2139,8 @@ class TestSkillResourceDecoratorEdgeCases:
return "data"
assert skill.resources[0].name == "custom-name"
# description falls back to docstring
assert skill.resources[0].description == "Some docs."
# description is None when not explicitly provided
assert skill.resources[0].description is None
def test_decorator_with_description_only(self) -> None:
skill = InlineSkill(name="my-skill", description="A skill.", instructions="Body")
@@ -2320,7 +2323,7 @@ class TestSkillScriptDecorator:
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "analyze"
assert skill.scripts[0].description == "Run analysis."
assert skill.scripts[0].description is None
assert isinstance(skill.scripts[0], InlineSkillScript)
assert skill.scripts[0].function is analyze
@@ -3177,6 +3180,757 @@ class TestLoadSkillWithScripts:
result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "<scripts>" not in result
# ---------------------------------------------------------------------------
# Tests: ClassSkill
# ---------------------------------------------------------------------------
class _MinimalClassSkill(ClassSkill):
"""A minimal class-based skill with no resources or scripts."""
def __init__(self) -> None:
super().__init__(name="minimal-skill", description="A minimal skill.")
@property
def instructions(self) -> str:
return "Do minimal things."
class _FullClassSkill(ClassSkill):
"""A class-based skill with resources and scripts."""
def __init__(self) -> None:
super().__init__(name="full-skill", description="A full skill.")
self._resources: list[SkillResource] | None = None
self._scripts: list[SkillScript] | None = None
@property
def instructions(self) -> str:
return "Use this skill for full tasks."
@property
def resources(self) -> list[SkillResource]:
if self._resources is None:
self._resources = [
InlineSkillResource(name="test-resource", content="Static resource content."),
]
return self._resources
@property
def scripts(self) -> list[SkillScript]:
if self._scripts is None:
self._scripts = [
InlineSkillScript(name="test-script", function=_class_skill_test_fn),
]
return self._scripts
def _class_skill_test_fn(value: float, factor: float) -> str:
"""Multiply value by factor."""
import json as _json
return _json.dumps({"result": round(value * factor, 4)})
class TestClassSkill:
"""Tests for ClassSkill abstract base class."""
def test_minimal_skill_has_no_resources(self) -> None:
skill = _MinimalClassSkill()
assert skill.resources == []
def test_minimal_skill_has_no_scripts(self) -> None:
skill = _MinimalClassSkill()
assert skill.scripts == []
def test_minimal_skill_content_contains_name(self) -> None:
skill = _MinimalClassSkill()
assert "<name>minimal-skill</name>" in skill.content
def test_minimal_skill_content_contains_description(self) -> None:
skill = _MinimalClassSkill()
assert "<description>A minimal skill.</description>" in skill.content
def test_minimal_skill_content_contains_instructions(self) -> None:
skill = _MinimalClassSkill()
assert "Do minimal things." in skill.content
def test_minimal_skill_content_no_resources_element(self) -> None:
skill = _MinimalClassSkill()
assert "<resources>" not in skill.content
def test_minimal_skill_content_no_scripts_element(self) -> None:
skill = _MinimalClassSkill()
assert "<scripts>" not in skill.content
def test_full_skill_has_resources(self) -> None:
skill = _FullClassSkill()
assert len(skill.resources) == 1
assert skill.resources[0].name == "test-resource"
def test_full_skill_has_scripts(self) -> None:
skill = _FullClassSkill()
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "test-script"
def test_full_skill_content_contains_resources(self) -> None:
skill = _FullClassSkill()
assert "<resources>" in skill.content
assert 'name="test-resource"' in skill.content
def test_full_skill_content_contains_scripts(self) -> None:
skill = _FullClassSkill()
assert "<scripts>" in skill.content
assert 'name="test-script"' in skill.content
def test_content_is_cached(self) -> None:
skill = _MinimalClassSkill()
content1 = skill.content
content2 = skill.content
assert content1 is content2
def test_resources_are_lazy_cached(self) -> None:
skill = _FullClassSkill()
resources1 = skill.resources
resources2 = skill.resources
assert resources1 is resources2
def test_scripts_are_lazy_cached(self) -> None:
skill = _FullClassSkill()
scripts1 = skill.scripts
scripts2 = skill.scripts
assert scripts1 is scripts2
def test_script_has_parameters_schema(self) -> None:
skill = _FullClassSkill()
script = skill.scripts[0]
assert isinstance(script, InlineSkillScript)
schema = script.parameters_schema
assert schema is not None
assert "value" in schema.get("properties", {})
assert "factor" in schema.get("properties", {})
async def test_provider_with_class_skill(self) -> None:
skill = _FullClassSkill()
provider = SkillsProvider([skill])
await _init_provider(provider)
skills = _raw_skills(provider)
assert len(skills) == 1
assert skills[0].name == "full-skill"
async def test_provider_loads_class_skill_content(self) -> None:
skill = _FullClassSkill()
provider = SkillsProvider([skill])
await _init_provider(provider)
result = provider._load_skill(_raw_skills(provider), "full-skill")
assert "Use this skill for full tasks." in result
assert "<resources>" in result
assert "<scripts>" in result
async def test_in_memory_source_with_class_skill(self) -> None:
skill = _MinimalClassSkill()
source = InMemorySkillsSource([skill])
skills = await source.get_skills()
assert len(skills) == 1
assert skills[0].name == "minimal-skill"
async def test_mixed_inline_and_class_skills(self) -> None:
inline = InlineSkill(name="inline-skill", description="Inline", instructions="inline body")
class_skill = _MinimalClassSkill()
provider = SkillsProvider([inline, class_skill])
await _init_provider(provider)
skills = _raw_skills(provider)
names = {s.name for s in skills}
assert names == {"inline-skill", "minimal-skill"}
async def test_class_skill_script_runs(self) -> None:
skill = _FullClassSkill()
script = skill.scripts[0]
result = await script.run(skill, {"value": 10.0, "factor": 2.5})
import json as _json
parsed = _json.loads(result)
assert parsed["result"] == 25.0
async def test_class_skill_resource_reads(self) -> None:
skill = _FullClassSkill()
resource = skill.resources[0]
content = await resource.read()
assert content == "Static resource content."
# ---------------------------------------------------------------------------
# Tests: ClassSkill with decorator-based discovery
# ---------------------------------------------------------------------------
class _DecoratorClassSkill(ClassSkill):
"""A class-based skill using @ClassSkill.resource and @ClassSkill.script decorators."""
def __init__(self) -> None:
super().__init__(name="decorator-skill", description="A decorator-discovered skill.")
@property
def instructions(self) -> str:
return "Use this skill for decorator tests."
@ClassSkill.resource(name="lookup-table")
def get_table(self) -> str:
"""Conversion lookup table."""
return "| From | To | Factor |"
@ClassSkill.script(name="convert")
def run_convert(self, value: float, factor: float) -> str:
"""Convert a value."""
import json as _json
return _json.dumps({"result": round(value * factor, 4)})
class _BareDecoratorSkill(ClassSkill):
"""Skill using bare decorators (no arguments) — name/description from method."""
def __init__(self) -> None:
super().__init__(name="bare-skill", description="Bare decorator skill.")
@property
def instructions(self) -> str:
return "Bare instructions."
@ClassSkill.resource
def my_table(self) -> str:
"""The table docs."""
return "table content"
@ClassSkill.script
def my_script(self, x: int) -> int:
"""Double x."""
return x * 2
class _DuplicateResourceSkill(ClassSkill):
"""Skill with duplicate resource names — should raise."""
def __init__(self) -> None:
super().__init__(name="dup-skill", description="Dup.")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="same-name")
def res_a(self) -> str:
return "a"
@ClassSkill.resource(name="same-name")
def res_b(self) -> str:
return "b"
class _DuplicateScriptSkill(ClassSkill):
"""Skill with duplicate script names — should raise."""
def __init__(self) -> None:
super().__init__(name="dup-script-skill", description="Dup.")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.script(name="same-name")
def script_a(self, x: int) -> int:
return x
@ClassSkill.script(name="same-name")
def script_b(self, x: int) -> int:
return x
class _SelfAccessSkill(ClassSkill):
"""Skill where resource/script access instance state via self."""
def __init__(self, multiplier: int = 10) -> None:
super().__init__(name="self-access", description="Self access skill.")
self.multiplier = multiplier
@property
def instructions(self) -> str:
return "Use multiplier."
@ClassSkill.resource(name="config")
def get_config(self) -> str:
return f"multiplier={self.multiplier}"
@ClassSkill.script(name="multiply")
def multiply(self, value: int) -> int:
return value * self.multiplier
class TestClassSkillDecoratorDiscovery:
"""Tests for decorator-based resource/script discovery on ClassSkill."""
def test_discovers_resources(self) -> None:
skill = _DecoratorClassSkill()
assert len(skill.resources) == 1
assert skill.resources[0].name == "lookup-table"
def test_discovers_scripts(self) -> None:
skill = _DecoratorClassSkill()
assert len(skill.scripts) == 1
assert skill.scripts[0].name == "convert"
def test_resource_description_from_decorator(self) -> None:
skill = _DecoratorClassSkill()
assert skill.resources[0].description is None
def test_script_description_from_decorator(self) -> None:
skill = _DecoratorClassSkill()
assert skill.scripts[0].description is None
def test_bare_decorator_name_from_method(self) -> None:
skill = _BareDecoratorSkill()
assert skill.resources[0].name == "my-table"
assert skill.scripts[0].name == "my-script"
def test_bare_decorator_description_is_none(self) -> None:
skill = _BareDecoratorSkill()
assert skill.resources[0].description is None
assert skill.scripts[0].description is None
async def test_resource_reads(self) -> None:
skill = _DecoratorClassSkill()
content = await skill.resources[0].read()
assert content == "| From | To | Factor |"
async def test_script_runs(self) -> None:
skill = _DecoratorClassSkill()
import json as _json
result = await skill.scripts[0].run(skill, {"value": 10.0, "factor": 2.5})
parsed = _json.loads(result)
assert parsed["result"] == 25.0
def test_script_schema_excludes_self(self) -> None:
skill = _DecoratorClassSkill()
script = skill.scripts[0]
assert isinstance(script, InlineSkillScript)
schema = script.parameters_schema
assert schema is not None
props = schema.get("properties", {})
assert "self" not in props
assert "value" in props
assert "factor" in props
def test_resources_cached(self) -> None:
skill = _DecoratorClassSkill()
r1 = skill.resources
r2 = skill.resources
assert r1 == r2
assert r1 is not r2 # defensive copy
def test_scripts_cached(self) -> None:
skill = _DecoratorClassSkill()
s1 = skill.scripts
s2 = skill.scripts
assert s1 == s2
assert s1 is not s2 # defensive copy
def test_content_includes_discovered_resources(self) -> None:
skill = _DecoratorClassSkill()
assert "<resources>" in skill.content
assert 'name="lookup-table"' in skill.content
def test_content_includes_discovered_scripts(self) -> None:
skill = _DecoratorClassSkill()
assert "<scripts>" in skill.content
assert 'name="convert"' in skill.content
def test_duplicate_resource_name_raises(self) -> None:
skill = _DuplicateResourceSkill()
with pytest.raises(ValueError, match="already has a resource named"):
_ = skill.resources
def test_duplicate_script_name_raises(self) -> None:
skill = _DuplicateScriptSkill()
with pytest.raises(ValueError, match="already has a script named"):
_ = skill.scripts
async def test_self_access_resource(self) -> None:
skill = _SelfAccessSkill(multiplier=42)
content = await skill.resources[0].read()
assert content == "multiplier=42"
async def test_self_access_script(self) -> None:
skill = _SelfAccessSkill(multiplier=3)
result = await skill.scripts[0].run(skill, {"value": 7})
assert result == 21
def test_no_decorators_yields_empty(self) -> None:
skill = _MinimalClassSkill()
assert skill.resources == []
assert skill.scripts == []
async def test_provider_with_decorator_skill(self) -> None:
skill = _DecoratorClassSkill()
provider = SkillsProvider([skill])
await _init_provider(provider)
skills = _raw_skills(provider)
assert len(skills) == 1
assert skills[0].name == "decorator-skill"
def test_manual_override_wins(self) -> None:
"""A subclass that overrides resources/scripts bypasses decorator discovery."""
skill = _FullClassSkill()
assert len(skill.resources) == 1
assert skill.resources[0].name == "test-resource"
async def test_property_resource_reads(self) -> None:
"""@ClassSkill.resource on a @property works correctly."""
skill = _PropertyResourceSkill()
assert len(skill.resources) == 1
assert skill.resources[0].name == "static-table"
content = await skill.resources[0].read()
assert "miles" in content
def test_property_resource_description_is_none_without_explicit(self) -> None:
skill = _PropertyResourceSkill()
assert skill.resources[0].description is None
def test_property_resource_in_content(self) -> None:
skill = _PropertyResourceSkill()
assert 'name="static-table"' in skill.content
async def test_mixed_property_and_method_resources(self) -> None:
"""Property and method resources can coexist."""
skill = _MixedPropertyMethodSkill()
names = {r.name for r in skill.resources}
assert names == {"prop-data", "method-data"}
for r in skill.resources:
content = await r.read()
assert "content" in content.lower()
def test_explicit_resource_description_in_object(self) -> None:
"""Explicit description= on @ClassSkill.resource is stored on the object."""
skill = _ExplicitDescriptionSkill()
res = next(r for r in skill.resources if r.name == "described-res")
assert res.description == "A described resource."
def test_explicit_script_description_in_object(self) -> None:
"""Explicit description= on @ClassSkill.script is stored on the object."""
skill = _ExplicitDescriptionSkill()
scr = next(s for s in skill.scripts if s.name == "described-scr")
assert scr.description == "A described script."
def test_explicit_description_in_content_xml(self) -> None:
"""Explicit descriptions appear in the skill content XML."""
skill = _ExplicitDescriptionSkill()
assert 'description="A described resource."' in skill.content
assert 'description="A described script."' in skill.content
def test_property_getter_not_called_during_discovery(self) -> None:
"""Property getter must NOT be evaluated when resources are discovered."""
skill = _PropertyCallCountSkill()
assert skill.getter_call_count == 0
_ = skill.resources # discovery should NOT call the getter
assert skill.getter_call_count == 0
async def test_property_getter_called_on_read(self) -> None:
"""Property getter IS evaluated when the resource is read."""
skill = _PropertyCallCountSkill()
_ = skill.resources
assert skill.getter_call_count == 0
await skill.resources[0].read()
assert skill.getter_call_count == 1
def test_make_method_name_strips_leading_trailing_hyphens(self) -> None:
"""_make_method_name strips leading/trailing underscores turned to hyphens."""
from agent_framework._skills import _make_method_name
assert _make_method_name("my_method") == "my-method"
assert _make_method_name("_private_method_") == "private-method"
assert _make_method_name("__dunder__") == "dunder"
assert _make_method_name("already_good") == "already-good"
def test_inherited_decorated_resources_are_discovered(self) -> None:
"""Decorated resources from a parent class are discovered on subclass."""
skill = _ChildSkill()
names = {r.name for r in skill.resources}
assert "parent-data" in names
def test_inherited_decorated_scripts_are_discovered(self) -> None:
"""Decorated scripts from a parent class are discovered on subclass."""
skill = _ChildSkill()
names = {s.name for s in skill.scripts}
assert "parent-action" in names
def test_child_can_add_own_resources(self) -> None:
"""A child class can add resources alongside inherited ones."""
skill = _ChildSkill()
names = {r.name for r in skill.resources}
assert "parent-data" in names
assert "child-data" in names
async def test_script_receives_kwargs(self) -> None:
"""ClassSkill scripts receive **kwargs forwarded from the runtime."""
skill = _KwargsSkill()
script = skill.scripts[0]
result = await script.run(skill, {"x": 5}, custom_key="hello")
assert result == "5-hello"
def test_wrong_decorator_order_resource_raises(self) -> None:
"""@ClassSkill.resource above @property raises TypeError at class definition."""
with pytest.raises(TypeError, match="must be applied before @property"):
class _BadOrder(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="oops") # wrong: should be below @property
@property
def bad_prop(self) -> str:
return "x"
def test_wrong_decorator_order_script_raises(self) -> None:
"""@ClassSkill.script on a property raises TypeError."""
with pytest.raises(TypeError, match="must be applied before"):
class _BadOrder(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.script(name="oops")
@property
def bad_prop(self) -> str:
return "x"
def test_invalid_explicit_resource_name_raises(self) -> None:
"""Invalid name= on @ClassSkill.resource raises ValueError at decoration."""
with pytest.raises(ValueError, match="Invalid @ClassSkill.resource name"):
class _BadName(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="UPPER CASE!")
def res(self) -> str:
return "x"
def test_invalid_explicit_script_name_raises(self) -> None:
"""Invalid name= on @ClassSkill.script raises ValueError at decoration."""
with pytest.raises(ValueError, match="Invalid @ClassSkill.script name"):
class _BadName(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.script(name="has spaces")
def scr(self, x: int) -> int:
return x
def test_empty_explicit_name_raises(self) -> None:
"""Empty name= on @ClassSkill.resource raises ValueError."""
with pytest.raises(ValueError, match="name cannot be empty"):
class _EmptyName(ClassSkill):
def __init__(self) -> None:
super().__init__(name="bad", description="bad")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="")
def res(self) -> str:
return "x"
def test_resources_copy_prevents_cache_mutation(self) -> None:
"""Mutating the returned resources list does not affect the cache."""
skill = _DecoratorClassSkill()
r1 = skill.resources
r1.clear()
r2 = skill.resources
assert len(r2) == 1 # original cached list is intact
def test_scripts_copy_prevents_cache_mutation(self) -> None:
"""Mutating the returned scripts list does not affect the cache."""
skill = _DecoratorClassSkill()
s1 = skill.scripts
s1.clear()
s2 = skill.scripts
assert len(s2) == 1 # original cached list is intact
async def test_inherited_property_resource_discovered(self) -> None:
"""A @property @ClassSkill.resource on a parent class is discovered on child."""
skill = _ChildWithInheritedPropertySkill()
names = {r.name for r in skill.resources}
assert "parent-prop" in names
content = await next(r for r in skill.resources if r.name == "parent-prop").read()
assert content == "parent property content"
# ---------------------------------------------------------------------------
# Helper skills for additional tests
# ---------------------------------------------------------------------------
class _ExplicitDescriptionSkill(ClassSkill):
"""Skill with explicit descriptions on decorator."""
def __init__(self) -> None:
super().__init__(name="desc-skill", description="Explicit desc.")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.resource(name="described-res", description="A described resource.")
def res(self) -> str:
return "data"
@ClassSkill.script(name="described-scr", description="A described script.")
def scr(self, x: int) -> int:
return x
class _PropertyCallCountSkill(ClassSkill):
"""Tracks how many times the property getter is called."""
def __init__(self) -> None:
super().__init__(name="callcount-skill", description="Tracks calls.")
self.getter_call_count = 0
@property
def instructions(self) -> str:
return "x"
@property
@ClassSkill.resource(name="counted")
def counted_resource(self) -> str:
self.getter_call_count += 1
return "counted"
class _ParentSkill(ClassSkill, ABC):
"""Parent with decorated resources/scripts."""
@ClassSkill.resource(name="parent-data")
def parent_resource(self) -> str:
return "parent"
@ClassSkill.script(name="parent-action")
def parent_script(self, x: int) -> int:
return x
class _ChildSkill(_ParentSkill):
"""Child inheriting parent resources and adding its own."""
def __init__(self) -> None:
super().__init__(name="child-skill", description="Child.")
@property
def instructions(self) -> str:
return "child"
@ClassSkill.resource(name="child-data")
def child_resource(self) -> str:
return "child"
class _KwargsSkill(ClassSkill):
"""Skill that uses **kwargs from runtime."""
def __init__(self) -> None:
super().__init__(name="kwargs-skill", description="Kwargs.")
@property
def instructions(self) -> str:
return "x"
@ClassSkill.script(name="echo")
def echo(self, x: int, **kwargs: Any) -> str:
return f"{x}-{kwargs.get('custom_key', 'none')}"
class _ParentWithPropertyResource(ClassSkill, ABC):
"""Parent with a property-based resource."""
@property
@ClassSkill.resource(name="parent-prop")
def parent_property(self) -> str:
return "parent property content"
class _ChildWithInheritedPropertySkill(_ParentWithPropertyResource):
"""Child that should discover inherited property resource."""
def __init__(self) -> None:
super().__init__(name="child-prop-skill", description="Child prop.")
@property
def instructions(self) -> str:
return "x"
class _PropertyResourceSkill(ClassSkill):
"""Skill with a property-based resource."""
def __init__(self) -> None:
super().__init__(name="prop-skill", description="Property skill.")
@property
def instructions(self) -> str:
return "Use this skill."
@property
@ClassSkill.resource(name="static-table")
def conversion_table(self) -> str:
"""Static conversion table."""
return "| miles | km | 1.60934 |"
class _MixedPropertyMethodSkill(ClassSkill):
"""Skill with both property and method resources."""
def __init__(self) -> None:
super().__init__(name="mixed-prop", description="Mixed.")
@property
def instructions(self) -> str:
return "x"
@property
@ClassSkill.resource(name="prop-data")
def static_data(self) -> str:
"""Static content."""
return "Property Content"
@ClassSkill.resource(name="method-data")
def dynamic_data(self) -> str:
"""Dynamic content."""
return "Method Content"
async def test_code_skill_scripts_element_contains_parameters(self) -> None:
"""Scripts XML includes parameters schema when the function has typed parameters."""