diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py
index eb439c3543..db1c43abfe 100644
--- a/python/packages/core/agent_framework/__init__.py
+++ b/python/packages/core/agent_framework/__init__.py
@@ -135,6 +135,7 @@
from ._settings import SecretString, load_settings
from ._skills import (
AggregatingSkillsSource,
+ ClassSkill,
DeduplicatingSkillsSource,
DelegatingSkillsSource,
FileSkill,
@@ -345,6 +346,7 @@
"ChatResponseUpdate",
"CheckResult",
"CheckpointStorage",
+ "ClassSkill",
"CompactionProvider",
"CompactionStrategy",
"Content",
@@ -352,8 +354,8 @@
"ContinuationToken",
"ConversationSplit",
"ConversationSplitter",
- "Default",
"DeduplicatingSkillsSource",
+ "Default",
"DelegatingSkillsSource",
"Edge",
"EdgeCondition",
diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py
index 082c6f1b69..a55f1aa3b2 100644
--- a/python/packages/core/agent_framework/_skills.py
+++ b/python/packages/core/agent_framework/_skills.py
@@ -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 @@
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.).
@@ -575,6 +578,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"{xml_escape(name)}\n"
+ f"{xml_escape(description)}\n"
+ "\n"
+ "\n"
+ f"{instructions}\n"
+ ""
+ )
+
+ if resources:
+ resource_lines = "\n".join(_create_resource_element(r) for r in resources)
+ result += f"\n\n\n{resource_lines}\n"
+
+ if scripts:
+ script_lines = "\n".join(_create_script_element(s) for s in scripts)
+ result += f"\n\n\n{script_lines}\n"
+
+ return result
+
+
+def _create_resource_element(resource: SkillResource) -> str:
+ """Create a self-closing ```` 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" "
+
+
@experimental(feature_id=ExperimentalFeature.SKILLS)
class InlineSkill(Skill):
"""A skill defined entirely in code with resources and scripts.
@@ -639,25 +701,10 @@ def content(self) -> str:
if self._cached_content is not None:
return self._cached_content
- result = (
- f"{xml_escape(self.name)}\n"
- f"{xml_escape(self.description)}\n"
- "\n"
- "\n"
- f"{self.instructions}\n"
- ""
+ 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\n{resource_lines}\n"
-
- if self._scripts:
- script_lines = "\n".join(_create_script_element(s) for s in self._scripts)
- result += f"\n\n\n{script_lines}\n"
-
- self._cached_content = result
- return result
+ return self._cached_content
@property
def resources(self) -> list[SkillResource]:
@@ -669,22 +716,6 @@ def scripts(self) -> list[SkillScript]:
"""Mutable list of :class:`SkillScript` instances."""
return self._scripts
- @staticmethod
- def _create_resource_element(resource: SkillResource) -> str:
- """Create a self-closing ```` 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" "
-
def resource(
self,
func: Callable[..., Any] | None = None,
@@ -705,8 +736,7 @@ def resource(
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
@@ -732,7 +762,7 @@ async def get_data() -> Any:
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,
@@ -766,8 +796,7 @@ def script(
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
@@ -794,7 +823,7 @@ async def fetch_data(url: str) -> str:
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,
@@ -809,6 +838,420 @@ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
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.
diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py
index 36906d55b8..55620ccce6 100644
--- a/python/packages/core/tests/core/test_skills.py
+++ b/python/packages/core/tests/core/test_skills.py
@@ -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 @@
from agent_framework import (
AggregatingSkillsSource,
+ ClassSkill,
DeduplicatingSkillsSource,
FileSkill,
FileSkillScript,
@@ -32,6 +34,7 @@
DEFAULT_SCRIPT_EXTENSIONS,
InlineSkillResource,
InlineSkillScript,
+ _create_resource_element,
_create_script_element,
_FileSkillResource,
)
@@ -1004,7 +1007,7 @@ def get_schema() -> Any:
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
@@ -1675,22 +1678,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 == ' '
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 == ' '
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 """ in elem
def test_xml_escapes_description(self) -> None:
r = InlineSkillResource(name="ref", description='Uses & "quotes"', content="data")
- elem = InlineSkill._create_resource_element(r)
+ elem = _create_resource_element(r)
assert "<tags>" in elem
assert "&" in elem
assert """ in elem
@@ -2129,8 +2132,8 @@ def get_data() -> Any:
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")
@@ -2313,7 +2316,7 @@ def analyze(query: str) -> str:
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
@@ -3168,6 +3171,757 @@ async def test_code_skill_no_scripts_element(self) -> None:
result = provider._load_skill(_raw_skills(provider), "my-skill")
assert "" 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 "minimal-skill" in skill.content
+
+ def test_minimal_skill_content_contains_description(self) -> None:
+ skill = _MinimalClassSkill()
+ assert "A minimal skill." 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 "" not in skill.content
+
+ def test_minimal_skill_content_no_scripts_element(self) -> None:
+ skill = _MinimalClassSkill()
+ assert "" 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 "" in skill.content
+ assert 'name="test-resource"' in skill.content
+
+ def test_full_skill_content_contains_scripts(self) -> None:
+ skill = _FullClassSkill()
+ assert "" 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 "" in result
+ assert "" 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 "" in skill.content
+ assert 'name="lookup-table"' in skill.content
+
+ def test_content_includes_discovered_scripts(self) -> None:
+ skill = _DecoratorClassSkill()
+ assert "" 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."""
def analyze(query: str, limit: int = 10) -> str:
diff --git a/python/samples/02-agents/skills/README.md b/python/samples/02-agents/skills/README.md
index b401278577..6e9bb08202 100644
--- a/python/samples/02-agents/skills/README.md
+++ b/python/samples/02-agents/skills/README.md
@@ -10,7 +10,8 @@ Start with file-based or code-defined skills, then explore combining them and ad
|--------|-------------|
| [**file_based_skill**](file_based_skill/) | Define skills as `SKILL.md` files on disk with reference documents and executable scripts. Uses the unit-converter skill. |
| [**code_defined_skill**](code_defined_skill/) | Define skills entirely in Python code using `Skill`, `@skill.resource`, and `@skill.script` decorators. Uses a code-defined unit-converter skill. |
-| [**mixed_skills**](mixed_skills/) | Combine code-defined and file-based skills in a single agent. Uses a code-defined volume-converter and a file-based unit-converter. |
+| [**class_based_skill**](class_based_skill/) | Define skills as Python classes using `ClassSkill` with `@ClassSkill.resource` and `@ClassSkill.script` decorators for auto-discovery. Uses a class-based unit-converter skill. |
+| [**mixed_skills**](mixed_skills/) | Combine code-defined, class-based, and file-based skills in a single agent. Uses a code-defined volume-converter, a class-based temperature-converter, and a file-based unit-converter. |
| [**script_approval**](script_approval/) | Require human-in-the-loop approval before executing skill scripts |
## Key Concepts
@@ -23,17 +24,18 @@ Skills use a three-step interaction model to minimize token usage:
2. **Load** — Full instructions are loaded on-demand via the `load_skill` tool
3. **Access** — Resources are read via `read_skill_resource`; scripts are executed via `run_skill_script`
-### File-Based vs Code-Defined Skills
+### File-Based vs Code-Defined vs Class-Based Skills
-| Aspect | File-Based | Code-Defined |
-|--------|-----------|--------------|
-| Definition | `SKILL.md` files on disk | `Skill` instances in Python |
-| Resources | Static files in `references/` and `assets/` directories | Callable functions via `@skill.resource` decorator |
-| Scripts | Python files in `scripts/` directory (executed via subprocess) | Callable functions via `@skill.script` decorator (executed in-process) |
-| Discovery | Automatic via `skill_paths` parameter | Explicit via `skills` parameter |
-| Dynamic content | No (static files only) | Yes (functions can generate content at runtime) |
+| Aspect | File-Based | Code-Defined | Class-Based |
+|--------|-----------|--------------|-------------|
+| Definition | `SKILL.md` files on disk | `Skill` instances in Python | Classes extending `ClassSkill` |
+| Resources | Static files in `references/` and `assets/` directories | Callable functions via `@skill.resource` decorator | `@ClassSkill.resource` decorator (auto-discovered) |
+| Scripts | Python files in `scripts/` directory (executed via subprocess) | Callable functions via `@skill.script` decorator (executed in-process) | `@ClassSkill.script` decorator (executed in-process) |
+| Discovery | Automatic via `skill_paths` parameter | Explicit via `skills` parameter | Explicit via `skills` parameter |
+| Dynamic content | No (static files only) | Yes (functions can generate content at runtime) | Yes (functions can generate content at runtime) |
+| Sharing pattern | Copy skill directory | Inline or shared instances | Package in shared libraries/PyPI |
-Both types can be combined in a single `SkillsProvider` — see the [mixed_skills](mixed_skills/) sample.
+All three types can be combined in a single `SkillsProvider` — see the [mixed_skills](mixed_skills/) sample.
### Script Execution
diff --git a/python/samples/02-agents/skills/class_based_skill/README.md b/python/samples/02-agents/skills/class_based_skill/README.md
new file mode 100644
index 0000000000..bf70e35db8
--- /dev/null
+++ b/python/samples/02-agents/skills/class_based_skill/README.md
@@ -0,0 +1,71 @@
+# Class-Based Agent Skills
+
+This sample demonstrates how to define **Agent Skills as Python classes** using `ClassSkill`.
+
+## What's Demonstrated
+
+- Creating skills as classes that extend `ClassSkill`
+- Bundling name, description, instructions, resources, and scripts into a single class
+- Using `@ClassSkill.resource` decorator for automatic resource discovery
+- Using `@ClassSkill.script` decorator for automatic script discovery
+- Lazy-loading and caching of resources and scripts
+- Registering class-based skills with `SkillsProvider`
+
+## Skills Included
+
+### unit-converter (class-based)
+
+A `UnitConverterSkill` class that converts between common units. Defined in `class_based_skill.py`:
+
+- `conversion-table` — Static resource with factor table
+- `convert` — Script that performs `value × factor` conversion
+
+## Project Structure
+
+```
+class_based_skill/
+├── class_based_skill.py
+└── README.md
+```
+
+## Running the Sample
+
+### Prerequisites
+
+- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
+
+### Environment Variables
+
+Set the required environment variables in a `.env` file (see `python/.env.example`):
+
+- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
+- `FOUNDRY_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`)
+
+### Authentication
+
+This sample uses `AzureCliCredential` for authentication. Run `az login` in your terminal before running the sample.
+
+### Run
+
+```bash
+cd python
+uv run samples/02-agents/skills/class_based_skill/class_based_skill.py
+```
+
+### Expected Output
+
+```
+Converting units with class-based skills
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **75 kg → 165.35 lbs**
+```
+
+## Learn More
+
+- [Agent Skills Specification](https://agentskills.io/)
+- [Code-Defined Skills Sample](../code_defined_skill/)
+- [Mixed Skills Sample](../mixed_skills/)
+- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/python/samples/02-agents/skills/class_based_skill/class_based_skill.py b/python/samples/02-agents/skills/class_based_skill/class_based_skill.py
new file mode 100644
index 0000000000..e2b480864f
--- /dev/null
+++ b/python/samples/02-agents/skills/class_based_skill/class_based_skill.py
@@ -0,0 +1,145 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import json
+import os
+
+# Uncomment this filter to suppress the experimental Skills warning before
+# using the sample's Skills APIs.
+# import warnings # isort: skip
+# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning)
+from textwrap import dedent
+
+from agent_framework import Agent, ClassSkill, SkillsProvider
+from agent_framework.foundry import FoundryChatClient
+from azure.identity import AzureCliCredential
+from dotenv import load_dotenv
+
+"""
+Class-Based Agent Skills — Define skills as Python classes
+
+This sample demonstrates how to define Agent Skills as reusable Python classes
+by subclassing ``ClassSkill``. Class-based skills bundle all components (name,
+description, instructions, resources, scripts) into a single class, making
+them easy to package and distribute via shared libraries or PyPI.
+
+Key concepts shown:
+- Subclassing ``ClassSkill`` to create a self-contained skill
+- Using ``@property`` + ``@ClassSkill.resource`` (bare) — name defaults to method name
+- Using ``@ClassSkill.script(name=..., description=...)`` — explicit name and description
+- Lazy-loading and caching of resources and scripts
+"""
+
+# Load environment variables from .env file
+load_dotenv()
+
+
+# ---------------------------------------------------------------------------
+# Class-Based Skill: UnitConverterSkill
+# ---------------------------------------------------------------------------
+
+
+class UnitConverterSkill(ClassSkill):
+ """A unit-converter skill defined as a Python class.
+
+ Converts between common units (miles↔km, pounds↔kg) using a
+ conversion factor. Resources and scripts are discovered automatically
+ via decorators.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(
+ name="unit-converter",
+ description=(
+ "Convert between common units using a multiplication factor. "
+ "Use when asked to convert miles, kilometers, pounds, or kilograms."
+ ),
+ )
+
+ @property
+ def instructions(self) -> str:
+ return dedent("""\
+ Use this skill when the user asks to convert between units.
+
+ 1. Review the conversion-table resource to find the factor for the requested conversion.
+ 2. Use the convert script, passing the value and factor from the table.
+ 3. Present the result clearly with both units.
+ """)
+
+ # 1. Property with bare decorator — name defaults to the method name
+ # ("conversion_table" → "conversion-table"), no description.
+ # Place @property first, then @ClassSkill.resource.
+ @property
+ @ClassSkill.resource
+ def conversion_table(self) -> str:
+ """Lookup table of multiplication factors for common unit conversions."""
+ return dedent("""\
+ # Conversion Tables
+
+ Formula: **result = value × factor**
+
+ | From | To | Factor |
+ |-------------|-------------|----------|
+ | miles | kilometers | 1.60934 |
+ | kilometers | miles | 0.621371 |
+ | pounds | kilograms | 0.453592 |
+ | kilograms | pounds | 2.20462 |
+ """)
+
+ # 2. Explicit name — overrides the method name
+ # 3. Explicit description — provides a description for the script
+ @ClassSkill.script(name="convert", description="Multiplies a value by a conversion factor.")
+ def convert_units(self, value: float, factor: float) -> str:
+ """Convert a value using a multiplication factor: result = value × factor.
+
+ Args:
+ value: The numeric value to convert.
+ factor: Conversion factor from the conversion table.
+
+ Returns:
+ JSON string with the inputs and converted result.
+ """
+ result = round(value * factor, 4)
+ return json.dumps({"value": value, "factor": factor, "result": result})
+
+
+async def main() -> None:
+ """Run the class-based skills demo."""
+ endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
+ deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
+
+ client = FoundryChatClient(
+ project_endpoint=endpoint,
+ model=deployment,
+ credential=AzureCliCredential(),
+ )
+
+ # Instantiate the class-based skill and pass it to the provider
+ unit_converter = UnitConverterSkill()
+
+ async with Agent(
+ client=client,
+ instructions="You are a helpful assistant that can convert units.",
+ context_providers=[SkillsProvider(unit_converter)],
+ ) as agent:
+ print("Converting units with class-based skills")
+ print("-" * 60)
+ response = await agent.run(
+ "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"
+ )
+ print(f"Agent: {response}\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+Converting units with class-based skills
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **75 kg → 165.35 lbs**
+"""
diff --git a/python/samples/02-agents/skills/mixed_skills/README.md b/python/samples/02-agents/skills/mixed_skills/README.md
index 8e5111266c..3b6832827e 100644
--- a/python/samples/02-agents/skills/mixed_skills/README.md
+++ b/python/samples/02-agents/skills/mixed_skills/README.md
@@ -1,17 +1,18 @@
-# Mixed Skills — Code Skills and File Skills
+# Mixed Skills — Code, Class, and File Skills
-This sample demonstrates how to combine **code-defined skills** and
-**file-based skills** in a single agent using a `SkillScriptRunner` callable
-and `SkillsProvider`.
+This sample demonstrates how to combine **code-defined skills**,
+**class-based skills**, and **file-based skills** in a single agent using
+`SkillsProvider`.
## Concepts
| Concept | Description |
|---------|-------------|
| **Code skill** | A `Skill` created in Python with `@skill.script` decorators for in-process callable functions and `@skill.resource` for dynamic content |
+| **Class skill** | A self-contained skill class extending `ClassSkill`, bundling instructions, resources, and scripts |
| **File skill** | A skill discovered from a `SKILL.md` file on disk, with reference documents and executable script files |
| **`script_runner`** | A callable (sync or async) satisfying the `SkillScriptRunner` protocol — required when file skills have scripts |
-| **`SkillsProvider`** | Registers both code-defined and file-based skills in a single provider |
+| **`SkillsProvider`** | Registers code-defined, class-based, and file-based skills in a single provider |
## Skills in This Sample
@@ -24,6 +25,15 @@ Defined entirely in Python code using decorators:
Code scripts run **in-process** — no subprocess or external runner needed.
+### temperature-converter (class skill)
+
+Defined as a `TemperatureConverterSkill` class extending `ClassSkill`:
+
+- **`@ClassSkill.resource`** — `temperature-conversion-formulas`: °F↔°C↔K formulas
+- **`@ClassSkill.script`** — `convert-temperature`: converts between temperature scales
+
+Class-based scripts run **in-process** — no subprocess or external runner needed.
+
### unit-converter (file skill)
Discovered from `skills/unit-converter/SKILL.md`:
@@ -43,7 +53,10 @@ File scripts are executed as **local Python subprocesses** via the
│ AggregatingSkillsSource([ │
│ FileSkillsSource("./skills", # file skills │
│ script_runner=runner), │
-│ InMemorySkillsSource([skill]), # code skills │
+│ InMemorySkillsSource([ │
+│ volume_skill, # code skill │
+│ temp_converter, # class skill │
+│ ]), │
│ ]) │
│ ) │
│ ) │
@@ -54,6 +67,7 @@ File scripts are executed as **local Python subprocesses** via the
│ script_runner(skill, script, args) │
│ │
│ • Code scripts (@skill.script) → in-process call │
+│ • Class scripts (@ClassSkill.script) → in-process call │
│ • File scripts (scripts/*.py) → subprocess via │
│ the callback function │
└─────────────────────────────────────────────────────────────┘
diff --git a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
index 998dc6e692..2b10fc0c2a 100644
--- a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
+++ b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
@@ -16,6 +16,7 @@
from agent_framework import (
Agent,
AggregatingSkillsSource,
+ ClassSkill,
DeduplicatingSkillsSource,
FileSkillsSource,
InlineSkill,
@@ -34,28 +35,32 @@
from subprocess_script_runner import subprocess_script_runner # noqa: E402
"""
-Mixed Skills — Code skills and file skills in a single agent
+Mixed Skills — Code, class, and file skills in a single agent
This sample demonstrates how to combine **code-defined skills** (with
-``@skill.script`` and ``@skill.resource`` decorators) and **file-based skills**
-(discovered from ``SKILL.md`` files on disk) in a single agent using
-``SkillsProvider`` and a ``SkillScriptRunner`` callable.
+``@skill.script`` and ``@skill.resource`` decorators), **class-based skills**
+(subclassing ``ClassSkill``), and **file-based skills** (discovered from
+``SKILL.md`` files on disk) in a single agent using ``SkillsProvider`` and
+a ``SkillScriptRunner`` callable.
Key concepts shown:
- Code skills with ``@skill.script``: executable Python functions the agent
can invoke directly in-process.
- Code skills with ``@skill.resource``: dynamic content the agent can read
on demand.
+- Class skills: self-contained skill classes extending ``ClassSkill``.
- File skills from disk: ``SKILL.md`` files with reference documents and
executable script files.
- ``script_runner``: routes **file-based** script execution
through a callback, enabling custom handling (e.g. subprocess calls).
- Code-defined scripts (``@skill.script``) run in-process automatically.
+ Code-defined and class-based scripts run in-process automatically.
-The sample registers two skills:
+The sample registers three skills:
1. **volume-converter** (code skill) — converts between gallons and liters using
``@skill.script`` for conversion and ``@skill.resource`` for the factor table.
-2. **unit-converter** (file skill) — converts between common units (miles↔km,
+2. **temperature-converter** (class skill) — converts between temperature scales
+ (°F↔°C↔K) using a ``ClassSkill`` subclass.
+3. **unit-converter** (file skill) — converts between common units (miles↔km,
pounds↔kg) via a subprocess-executed Python script discovered from
``skills/unit-converter/SKILL.md``.
"""
@@ -110,9 +115,68 @@ def convert_volume(value: float, factor: float) -> str:
# ---------------------------------------------------------------------------
-# 2. Wire everything together and run the agent
+# 2. Define a class-based skill for temperature conversion
# ---------------------------------------------------------------------------
+class TemperatureConverterSkill(ClassSkill):
+ """A temperature-converter skill defined as a Python class.
+
+ Converts between temperature scales (Fahrenheit, Celsius, Kelvin).
+ Resources and scripts are discovered automatically via decorators.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(
+ name="temperature-converter",
+ description="Convert between temperature scales (Fahrenheit, Celsius, Kelvin).",
+ )
+
+ @property
+ def instructions(self) -> str:
+ return dedent("""\
+ Use this skill when the user asks to convert temperatures.
+
+ 1. Read the temperature-conversion-formulas resource to find the factor and offset
+ for the requested conversion.
+ 2. Use the convert-temperature script, passing value, factor, and offset.
+ 3. Present the result clearly with both temperature scales.
+ """)
+
+ @ClassSkill.resource(name="temperature-conversion-formulas")
+ def formulas(self) -> str:
+ """Temperature conversion formulas reference table."""
+ return dedent("""\
+ # Temperature Conversion Formulas
+
+ Formula: **result = value × factor + offset**
+
+ | From | To | Factor | Offset |
+ |-------------|-------------|----------|-----------|
+ | Fahrenheit | Celsius | 0.555556 | -17.7778 |
+ | Celsius | Fahrenheit | 1.8 | 32 |
+ | Celsius | Kelvin | 1 | 273.15 |
+ | Kelvin | Celsius | 1 | -273.15 |
+ """)
+
+ @ClassSkill.script(name="convert-temperature")
+ def convert_temperature(self, value: float, factor: float, offset: float = 0) -> str:
+ """Convert a temperature value using factor and offset from the formulas resource.
+
+ Args:
+ value: The numeric temperature value to convert.
+ factor: Conversion factor from the formulas resource.
+ offset: Offset to add after multiplying (default 0).
+
+ Returns:
+ JSON string with the conversion result.
+ """
+ result = round(value * factor + offset, 4)
+ return json.dumps({"value": value, "factor": factor, "offset": offset, "result": result})
+
+
+# ---------------------------------------------------------------------------
+# 3. Wire everything together and run the agent
+# ---------------------------------------------------------------------------
async def main() -> None:
"""Run the combined skills demo."""
@@ -126,9 +190,11 @@ async def main() -> None:
credential=AzureCliCredential(),
)
- # Create the SkillsProvider with both code and file skills.
- # The script_runner handles file-based scripts; code-defined scripts
- # (@skill.script) run in-process automatically.
+ # Create the SkillsProvider with code, class, and file skills.
+ # The script_runner handles file-based scripts; code-defined and
+ # class-based scripts run in-process automatically.
+ temperature_converter = TemperatureConverterSkill()
+
skills_provider = SkillsProvider(
DeduplicatingSkillsSource(
AggregatingSkillsSource([
@@ -136,7 +202,7 @@ async def main() -> None:
str(Path(__file__).parent / "skills"),
script_runner=subprocess_script_runner,
),
- InMemorySkillsSource([volume_converter_skill]),
+ InMemorySkillsSource([volume_converter_skill, temperature_converter]),
])
)
)
@@ -144,14 +210,17 @@ async def main() -> None:
# Run the agent
async with Agent(
client=client,
- instructions="You are a helpful assistant that can convert units.",
+ instructions="You are a helpful assistant that can convert units, volumes, and temperatures.",
context_providers=[skills_provider],
) as agent:
- # Ask the agent to use both skills
- print("Converting units")
+ # Ask the agent to use all three skills
+ print("Converting with mixed skills (file + code + class)")
print("-" * 60)
response = await agent.run(
- "How many kilometers is a marathon (26.2 miles)? And how many liters is a 5-gallon bucket?"
+ "I need three conversions: "
+ "1) How many kilometers is a marathon (26.2 miles)? "
+ "2) How many liters is a 5-gallon bucket? "
+ "3) What is 98.6°F in Celsius?"
)
print(f"Agent: {response}\n")
@@ -162,12 +231,11 @@ async def main() -> None:
"""
Sample output:
-Converting units
+Converting with mixed skills (file + code + class)
------------------------------------------------------------
Agent: Here are your conversions:
1. **26.2 miles → 42.16 km** (a marathon distance)
2. **5 gallons → 18.93 liters**
-
-I used the conversion factors from each skill's reference table.
+3. **98.6°F → 37.0°C**
"""