Python: [Breaking] Restructure agent skills to use multi-source architecture (#5584)

* migrate skills to multi source architecture

* Fix ruff lint errors in skills module (ASYNC240, SIM108, E501)

- Use anyio.Path for async file I/O in _FileSkillResource.read()
- Use noqa: ASYNC240 for pure string os.path calls in async context
- Restore pre-commit if/else pattern in InlineSkillScript.run()
- Break long lines to fit 120-char limit in _skills.py and test_skills.py

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

* fix: collapse multi-line lambdas to single lines to fix pyright errors

The pyright ignore comments only suppress errors on the same line, so
multi-line lambdas left arguments on continuation lines uncovered.
Collapse both lambdas to single lines matching the existing load_skill
lambda pattern.

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

* fix: replace untyped lambdas with typed inner functions to fix pyright errors

Python lambdas cannot have type annotations, so pyright reports
reportUnknownLambdaType and reportUnknownArgumentType errors that
cannot be suppressed with inline ignore comments. Replace the
lambdas for read_skill_resource and run_skill_script with typed
inner async functions.

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

* fix: address PR review feedback on docs and prompt template

- Update with_prompt_template() docstring to document the
  {resource_instructions} placeholder requirement
- Remove stray backslashes after {resource_instructions} and
  {runner_instructions} in DEFAULT_SKILLS_INSTRUCTION_PROMPT
- Update subprocess_script_runner docstring to reflect
  FileSkillScript.full_path usage

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

* refactor: replace dict[str, Skill] with Sequence[Skill] in SkillsProvider

Replace internal dict-based skills storage with Sequence[Skill] to
eliminate silent duplicate overwrites and simplify the code. Add
_find_skill helper for case-insensitive linear lookup.

Also fix pyright errors in tests by adding isinstance assertions
before accessing .function on SkillResource/SkillScript base types.

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

* refactor: add read-time resource path validation in _FileSkillsSource

Move security validation (path-traversal and symlink guards) for
file-based skill resources into _FileSkillsSource, restoring the
read-time checks that existed in main via _read_file_skill_resource.

- Add _get_validated_resource_path static method on _FileSkillsSource
  that validates containment, existence, and symlink safety
- _FileSkillsSource.get_skills() validates resource paths at discovery
  time via _get_validated_resource_path before passing to _FileSkillResource
- Move _normalize_resource_path, _is_path_within_directory, and
  _has_symlink_in_path from module-level into _FileSkillsSource as
  static methods (only used there)
- _FileSkillResource remains a simple path-to-content reader
- Add tests for _get_validated_resource_path security checks

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

* fix: reject str/Path in SkillsProvider constructor to prevent str-as-Sequence ambiguity

Since str is a Sequence, passing a path string to the source parameter
would silently be treated as a sequence of characters instead of a
file source. Add an explicit TypeError with a helpful message pointing
callers to SkillsProvider.from_paths().

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

* Address PR #5584 review feedback

- Remove .NET reference from _FileSkillResource docstring
- Fix inconsistent resource name example (references/FAQ.md -> references/FAQ)
- Simplify SkillsProvider usage in code_defined_skill sample (pass single skill directly)

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

* remove skillsproviderbuilder

* Update python/packages/core/agent_framework/_skills.py

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>

* fix: remove dead code and fix sync function call in InlineSkillResource.read()

- Change await self.function() to self.function() for sync functions
  without **kwargs; async results are handled by inspect.isawaitable()
- Remove unreachable raise ValueError since __init__ already validates

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

* remove full_path unnecessary property

* replace anyio with asyncio.to_thread for file I/O in _FileSkillResource

Replace anyio.Path usage with asyncio.to_thread + pathlib.Path since
anyio is not a direct dependency of core (transitive via mcp).

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

* simplify awaitable check to return directly

Use 'return await result' instead of assigning then returning.

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

* address PR review feedback for skills refactoring

- Replace anyio with asyncio.to_thread + pathlib.Path for file I/O
- Simplify awaitable check to return directly
- Remove unnecessary function None guard in InlineSkillResource.read()
- Add assert for type narrowing on self.function

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

* address PR review feedback for skills refactoring

- Replace anyio with asyncio.to_thread + pathlib.Path for file I/O
- Simplify awaitable checks to return directly
- Remove unnecessary function None guard in InlineSkillResource.read()
- Use typing.cast instead of assert for type narrowing
- Add caching behavior note to SkillsProvider docstring

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

* refactor: move name/description from abstract properties to Skill.__init__

Replace abstract properties for name and description on the Skill ABC
with a base __init__ that validates and stores them as regular
attributes. This simplifies custom Skill subclasses (only content
remains abstract) and centralizes validation in the base class,
consistent with SkillResource and SkillScript base classes.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2026-05-06 10:45:06 +01:00
committed by GitHub
Unverified
parent 705473c276
commit be8d2619e4
15 changed files with 3681 additions and 1748 deletions
@@ -11,7 +11,7 @@ import os
from textwrap import dedent
from typing import Any
from agent_framework import Agent, Skill, SkillResource, SkillsProvider
from agent_framework import Agent, InlineSkill, InlineSkillResource, SkillsProvider
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -46,10 +46,10 @@ load_dotenv()
# ---------------------------------------------------------------------------
# 1. Static Resources — inline content passed at construction time
# ---------------------------------------------------------------------------
unit_converter_skill = Skill(
unit_converter_skill = InlineSkill(
name="unit-converter",
description="Convert between common units using a conversion factor",
content=dedent("""\
instructions=dedent("""\
Use this skill when the user asks to convert between units.
1. Review the conversion-tables resource to find the factor for the
@@ -58,7 +58,7 @@ unit_converter_skill = Skill(
3. Use the convert script, passing the value and factor from the table.
"""),
resources=[
SkillResource(
InlineSkillResource(
name="conversion-tables",
content=dedent("""\
# Conversion Tables
@@ -146,7 +146,7 @@ async def main() -> None:
async with Agent(
client=client,
instructions="You are a helpful assistant that can convert units.",
context_providers=[SkillsProvider(skills=[unit_converter_skill])],
context_providers=[SkillsProvider(unit_converter_skill)],
) as agent:
print("Converting units")
print("-" * 60)
@@ -59,7 +59,7 @@ async def main() -> None:
# Discovers skills from the 'skills' directory and configures the
# subprocess_script_runner to run file-based scripts.
skills_dir = Path(__file__).parent / "skills"
skills_provider = SkillsProvider(
skills_provider = SkillsProvider.from_paths(
skill_paths=str(skills_dir),
script_runner=subprocess_script_runner,
)
@@ -38,11 +38,15 @@ File scripts are executed as **local Python subprocesses** via the
```
┌─────────────────────────────────────────────────────────────┐
│ SkillsProvider(
skill_paths="./skills", # file skills
skills=[volume_converter_skill], # code skills
script_runner=runner,
)
│ SkillsProvider( │
DeduplicatingSkillsSource(
AggregatingSkillsSource([
FileSkillsSource("./skills", # file skills
script_runner=runner),
│ InMemorySkillsSource([skill]), # code skills │
│ ]) │
│ ) │
│ ) │
└─────────────┬───────────────────────────────────────────────┘
@@ -15,7 +15,11 @@ from typing import Any
from agent_framework import (
Agent,
Skill,
AggregatingSkillsSource,
DeduplicatingSkillsSource,
FileSkillsSource,
InlineSkill,
InMemorySkillsSource,
SkillsProvider,
)
from agent_framework.foundry import FoundryChatClient
@@ -63,10 +67,10 @@ load_dotenv()
# 1. Define a code skill with @skill.script and @skill.resource decorators
# ---------------------------------------------------------------------------
volume_converter_skill = Skill(
volume_converter_skill = InlineSkill(
name="volume-converter",
description="Convert between gallons and liters using a conversion factor",
content=dedent("""\
instructions=dedent("""\
Use this skill when the user asks to convert between gallons and liters.
1. Review the conversion-table resource to find the correct factor.
@@ -125,11 +129,16 @@ async def main() -> None:
# 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.
skills_dir = Path(__file__).parent / "skills"
skills_provider = SkillsProvider(
skill_paths=str(skills_dir),
skills=[volume_converter_skill],
script_runner=subprocess_script_runner,
DeduplicatingSkillsSource(
AggregatingSkillsSource([
FileSkillsSource(
str(Path(__file__).parent / "skills"),
script_runner=subprocess_script_runner,
),
InMemorySkillsSource([volume_converter_skill]),
])
)
)
# Run the agent
@@ -9,7 +9,7 @@ import os
# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning)
from textwrap import dedent
from agent_framework import Agent, Skill, SkillsProvider
from agent_framework import Agent, InlineSkill, SkillsProvider
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -42,10 +42,10 @@ Prerequisites:
load_dotenv()
# Define a code skill with a script that performs a sensitive operation
deployment_skill = Skill(
deployment_skill = InlineSkill(
name="deployment",
description="Tools for deploying application versions to production",
content=dedent("""\
instructions=dedent("""\
Use this skill when the user asks to deploy an application.
1. Run the deploy script with the version and environment parameters.
@@ -72,7 +72,7 @@ async def main() -> None:
# Create the skills provider with script approval enabled
skills_provider = SkillsProvider(
skills=[deployment_skill],
source=[deployment_skill],
require_script_approval=True,
)
@@ -0,0 +1,94 @@
# Skill Filtering — FilteringSkillsSource
This sample demonstrates how to use `FilteringSkillsSource` to control
which file-based skills an agent sees by applying a predicate.
## Concepts
| Concept | Description |
|---------|-------------|
| **`FileSkillsSource`** | Discovers skills from `SKILL.md` files on disk |
| **`FilteringSkillsSource`** | Wraps a source and applies a predicate to include or exclude skills |
| **`DeduplicatingSkillsSource`** | Removes duplicate skill names (first-one-wins) |
## Skills in This Sample
### volume-converter (kept)
Converts between gallons and liters via `scripts/convert.py`.
### length-converter (filtered out)
Converts between miles ↔ km, feet ↔ meters. Excluded by the filter predicate
so the agent never sees it.
## How It Works
```
┌──────────────────────────────────────────────────────────┐
│ FileSkillsSource("./skills") │
│ discovers: volume-converter, length-converter │
└─────────────┬────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ FilteringSkillsSource(predicate=...) │
│ keeps: volume-converter │
│ drops: length-converter │
└─────────────┬────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ DeduplicatingSkillsSource → SkillsProvider │
└──────────────────────────────────────────────────────────┘
```
> **Note:** `FilteringSkillsSource` works with any source — file-based,
> in-memory, custom, or a mix. If you only need a single skill, point
> `FileSkillsSource` directly at that skill's directory instead of filtering.
## Prerequisites
Set environment variables (or create a `.env` file):
```
FOUNDRY_PROJECT_ENDPOINT=https://your-project.openai.azure.com/
FOUNDRY_MODEL=gpt-4o-mini
```
Authenticate with Azure CLI:
```bash
az login
```
## Running the Sample
```bash
cd python
uv run samples/02-agents/skills/skill_filtering/skill_filtering.py
```
## Directory Structure
```
skill_filtering/
├── skill_filtering.py
├── README.md
└── skills/
├── volume-converter/
│ ├── SKILL.md
│ └── scripts/
│ └── convert.py
└── length-converter/
├── SKILL.md
└── scripts/
└── convert.py
```
## Learn More
- [File-Based Skills Sample](../file_based_skill/)
- [Mixed Skills Sample](../mixed_skills/)
- [Code-Defined Skills Sample](../code_defined_skill/)
- [Agent Skills Specification](https://agentskills.io/)
@@ -0,0 +1,107 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
import sys
from pathlib import Path
from agent_framework import (
Agent,
DeduplicatingSkillsSource,
FileSkillsSource,
FilteringSkillsSource,
SkillsProvider,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Add the skills folder root to sys.path so the shared subprocess_script_runner can be imported
_SKILLS_ROOT = str(Path(__file__).resolve().parent.parent)
if _SKILLS_ROOT not in sys.path:
sys.path.insert(0, _SKILLS_ROOT)
from subprocess_script_runner import subprocess_script_runner # noqa: E402
"""
Skill Filtering — Using FilteringSkillsSource with file-based skills
This sample demonstrates how to use **FilteringSkillsSource** to control
which skills an agent sees. Although this example uses file-based skills,
``FilteringSkillsSource`` works equally well with in-memory skills,
custom sources, or any combination of them.
A single ``skills/`` directory contains two file-based skills discovered via
``FileSkillsSource``:
- **volume-converter** — converts between gallons and liters
- **length-converter** — converts between miles ↔ km, feet ↔ meters
A ``FilteringSkillsSource`` wraps the file source and excludes the
``length-converter`` skill, so the agent only sees the volume-converter skill.
Note: if you only need a single skill, you could point ``FileSkillsSource``
directly at that skill's directory and skip filtering entirely. This sample
intentionally points at the parent directory to demonstrate filtering.
Key concepts shown:
1. **FileSkillsSource** — discovers skills from ``SKILL.md`` files on disk.
2. **FilteringSkillsSource** — applies a predicate to include or exclude
specific skills by name (or any custom logic).
3. **DeduplicatingSkillsSource** — ensures no duplicate skill names survive.
"""
# Load environment variables from .env file
load_dotenv()
async def main() -> None:
"""Run the skill filtering demo."""
endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
# 1. Create the chat client
client = FoundryChatClient(
project_endpoint=endpoint,
model=deployment,
credential=AzureCliCredential(),
)
# 2. Compose the source pipeline:
# file discovery → filter out length-converter → deduplicate
skills_dir = Path(__file__).parent / "skills"
source = DeduplicatingSkillsSource(
FilteringSkillsSource(
FileSkillsSource(str(skills_dir), script_runner=subprocess_script_runner),
# Only keep the volume-converter skill
predicate=lambda s: s.name != "length-converter",
)
)
skills_provider = SkillsProvider(source)
# 3. Run the agent — it can only see the volume-converter skill
async with Agent(
client=client,
instructions="You are a helpful assistant that can convert units.",
context_providers=[skills_provider],
) as agent:
print("Skill filtering demo")
print("-" * 60)
response = await agent.run("How many liters is a 5-gallon bucket?")
print(f"Agent: {response}\n")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
Skill filtering demo
------------------------------------------------------------
Agent: A 5-gallon bucket is equal to **18.9271 liters**.
I used the conversion factor: 5 × 3.78541 = 18.9271
"""
@@ -0,0 +1,15 @@
---
name: length-converter
description: Convert between common length units (miles, km, feet, meters) using a multiplication factor.
---
## Usage
When the user requests a length conversion, run the `scripts/convert.py`
script with `--value <number> --factor <factor>`.
Common factors:
- miles → km: 1.60934
- km → miles: 0.621371
- feet → meters: 0.3048
- meters → feet: 3.28084
@@ -0,0 +1,18 @@
"""Convert a value by multiplying it with a factor."""
import argparse
import json
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--value", type=float, required=True)
parser.add_argument("--factor", type=float, required=True)
args = parser.parse_args()
result = round(args.value * args.factor, 4)
print(json.dumps({"value": args.value, "factor": args.factor, "result": result}))
if __name__ == "__main__":
main()
@@ -0,0 +1,11 @@
---
name: volume-converter
description: Convert between gallons and liters using a conversion factor.
---
## Usage
When the user requests a volume conversion:
1. Run the `scripts/convert.py` script with `--value <number> --factor <factor>`
2. Use factor 3.78541 for gallons → liters, or 0.264172 for liters → gallons
3. Present the converted value clearly with both units
@@ -0,0 +1,18 @@
"""Convert a value by multiplying it with a factor."""
import argparse
import json
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--value", type=float, required=True)
parser.add_argument("--factor", type=float, required=True)
args = parser.parse_args()
result = round(args.value * args.factor, 4)
print(json.dumps({"value": args.value, "factor": args.factor, "result": result}))
if __name__ == "__main__":
main()
@@ -17,25 +17,21 @@ import sys
from pathlib import Path
from typing import Any
from agent_framework import Skill, SkillScript
from agent_framework import FileSkill, FileSkillScript
def subprocess_script_runner(skill: Skill, script: SkillScript, args: dict[str, Any] | None = None) -> str:
def subprocess_script_runner(skill: FileSkill, script: FileSkillScript, args: dict[str, Any] | None = None) -> str:
"""Run a skill script as a local Python subprocess.
Resolves the script's absolute path from the skill directory, converts
the ``args`` dict to CLI flags, and returns captured output.
Uses ``FileSkillScript.full_path`` as the script path, converts the
``args`` dict to CLI flags, and returns captured output.
Args:
skill: The skill that owns the script.
script: The script to run.
skill: The file-based skill that owns the script.
script: The file-based script to run.
args: Optional arguments forwarded as CLI flags.
Returns:
The combined stdout/stderr output, or an error message.
"""
if not skill.path:
return f"Error: Skill '{skill.name}' has no directory path."
if not script.path:
return f"Error: Script '{script.name}' has no file path. Only file-based scripts can be executed locally."
script_path = Path(skill.path) / script.path
script_path = Path(script.full_path)
if not script_path.is_file():
return f"Error: Script file not found: {script_path}"
cmd = [sys.executable, str(script_path)]