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** """