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

* Python: Add ClassSkill for class-based skill definitions

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

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

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

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

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

* address PR review feedback

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

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

* Fix temperature converter skill: make resource necessary for script

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

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2026-05-07 20:39:12 +01:00
committed by GitHub
Unverified
parent a478d1b53c
commit 1d94518f37
8 changed files with 1583 additions and 84 deletions
+12 -10
View File
@@ -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**
"""