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