From 23ebfbc9374cfcd20e1c82623b699f552c1abbef Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Wed, 11 Mar 2026 18:28:30 +0000
Subject: [PATCH] Python: Support skill scripts execution (#4558)
* support skill scripts execution
* fix mixed line endings
* address comments and fix syntax issues
* use few try/except instead of one
* change samples
* validate either script path or script resource is set not both
* fix: separate LLM args from runtime kwargs in skill script execution
* address pr review comments
* address PR review comments
* Update python/packages/core/agent_framework/_skills.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update python/packages/core/agent_framework/_skills.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update python/packages/core/agent_framework/_skills.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* 1. Fixing the caching bug where parameters_schema would re-inspect on every call when the result was None
2. Updating the arguments tool description to be more generic (not CLI-specific)
* fix failing tests
* address pr review comments
* address pr review comments
* allow resource function returning any instead of sting
* address PR review comments
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
python/packages/core/AGENTS.md | 9 +
.../packages/core/agent_framework/__init__.py | 10 +-
.../packages/core/agent_framework/_skills.py | 566 ++++++-
.../packages/core/tests/core/test_skills.py | 1414 ++++++++++++++++-
python/samples/02-agents/skills/README.md | 55 +
.../02-agents/skills/basic_skill/README.md | 68 -
.../skills/basic_skill/basic_skill.py | 88 -
.../skills/expense-report/SKILL.md | 40 -
.../assets/expense-report-template.md | 5 -
.../expense-report/references/POLICY_FAQ.md | 55 -
.../skills/code_defined_skill/README.md | 49 +
.../code_defined_skill/code_defined_skill.py | 173 ++
.../02-agents/skills/code_skill/README.md | 57 -
.../02-agents/skills/code_skill/code_skill.py | 161 --
.../skills/file_based_skill/README.md | 69 +
.../file_based_skill/file_based_skill.py | 94 ++
.../skills/unit-converter/SKILL.md | 11 +
.../references/CONVERSION_TABLES.md | 10 +
.../skills/unit-converter/scripts/convert.py | 29 +
.../02-agents/skills/mixed_skills/README.md | 100 ++
.../skills/mixed_skills/mixed_skills.py | 160 ++
.../skills/unit-converter/SKILL.md | 11 +
.../references/CONVERSION_TABLES.md | 10 +
.../skills/unit-converter/scripts/convert.py | 29 +
.../skills/script_approval/README.md | 50 +
.../skills/script_approval/script_approval.py | 124 ++
.../skills/subprocess_script_runner.py | 75 +
27 files changed, 2994 insertions(+), 528 deletions(-)
create mode 100644 python/samples/02-agents/skills/README.md
delete mode 100644 python/samples/02-agents/skills/basic_skill/README.md
delete mode 100644 python/samples/02-agents/skills/basic_skill/basic_skill.py
delete mode 100644 python/samples/02-agents/skills/basic_skill/skills/expense-report/SKILL.md
delete mode 100644 python/samples/02-agents/skills/basic_skill/skills/expense-report/assets/expense-report-template.md
delete mode 100644 python/samples/02-agents/skills/basic_skill/skills/expense-report/references/POLICY_FAQ.md
create mode 100644 python/samples/02-agents/skills/code_defined_skill/README.md
create mode 100644 python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py
delete mode 100644 python/samples/02-agents/skills/code_skill/README.md
delete mode 100644 python/samples/02-agents/skills/code_skill/code_skill.py
create mode 100644 python/samples/02-agents/skills/file_based_skill/README.md
create mode 100644 python/samples/02-agents/skills/file_based_skill/file_based_skill.py
create mode 100644 python/samples/02-agents/skills/file_based_skill/skills/unit-converter/SKILL.md
create mode 100644 python/samples/02-agents/skills/file_based_skill/skills/unit-converter/references/CONVERSION_TABLES.md
create mode 100644 python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py
create mode 100644 python/samples/02-agents/skills/mixed_skills/README.md
create mode 100644 python/samples/02-agents/skills/mixed_skills/mixed_skills.py
create mode 100644 python/samples/02-agents/skills/mixed_skills/skills/unit-converter/SKILL.md
create mode 100644 python/samples/02-agents/skills/mixed_skills/skills/unit-converter/references/CONVERSION_TABLES.md
create mode 100644 python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py
create mode 100644 python/samples/02-agents/skills/script_approval/README.md
create mode 100644 python/samples/02-agents/skills/script_approval/script_approval.py
create mode 100644 python/samples/02-agents/skills/subprocess_script_runner.py
diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md
index a270bc1686..859858f0ef 100644
--- a/python/packages/core/AGENTS.md
+++ b/python/packages/core/AGENTS.md
@@ -13,6 +13,7 @@ agent_framework/
├── _tools.py # Tool definitions and function invocation
├── _middleware.py # Middleware system for request/response interception
├── _sessions.py # AgentSession and context provider abstractions
+├── _skills.py # Agent Skills system (models, executors, provider)
├── _mcp.py # Model Context Protocol support
├── _workflows/ # Workflow orchestration (sequential, concurrent, handoff, etc.)
├── openai/ # Built-in OpenAI client
@@ -63,6 +64,14 @@ agent_framework/
- **`BaseContextProvider`** - Base class for context providers (RAG, memory systems)
- **`BaseHistoryProvider`** - Base class for conversation history storage
+### Skills (`_skills.py`)
+
+- **`Skill`** - A skill definition bundling instructions (`content`) with metadata, resources, and scripts. Supports `@skill.resource` and `@skill.script` decorators for adding components.
+- **`SkillResource`** - Named supplementary content attached to a skill; holds either static `content` or a dynamic `function` (sync or async). Exactly one must be provided.
+- **`SkillScript`** - An executable script attached to a skill; holds either an inline `function` (code-defined, runs in-process) or a `path` to a file on disk (file-based, delegated to a runner). Exactly one must be provided.
+- **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner.
+- **`SkillsProvider`** - Context provider (extends `BaseContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts.
+
### Workflows (`_workflows/`)
- **`Workflow`** - Graph-based workflow definition
diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py
index ef03652898..d7bc38220a 100644
--- a/python/packages/core/agent_framework/__init__.py
+++ b/python/packages/core/agent_framework/__init__.py
@@ -59,7 +59,13 @@ from ._sessions import (
register_state_type,
)
from ._settings import SecretString, load_settings
-from ._skills import Skill, SkillResource, SkillsProvider
+from ._skills import (
+ Skill,
+ SkillResource,
+ SkillScript,
+ SkillScriptRunner,
+ SkillsProvider,
+)
from ._telemetry import (
AGENT_FRAMEWORK_USER_AGENT,
APP_INFO,
@@ -271,6 +277,8 @@ __all__ = [
"SingleEdgeGroup",
"Skill",
"SkillResource",
+ "SkillScript",
+ "SkillScriptRunner",
"SkillsProvider",
"SubWorkflowRequestMessage",
"SubWorkflowResponseMessage",
diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py
index fc71329a5f..b7b91919e8 100644
--- a/python/packages/core/agent_framework/_skills.py
+++ b/python/packages/core/agent_framework/_skills.py
@@ -26,13 +26,14 @@ Only use skills from trusted sources.
from __future__ import annotations
import inspect
+import json
import logging
import os
import re
from collections.abc import Callable, Sequence
from html import escape as xml_escape
from pathlib import Path, PurePosixPath
-from typing import TYPE_CHECKING, Any, ClassVar, Final
+from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, runtime_checkable
from ._sessions import BaseContextProvider
from ._tools import FunctionTool
@@ -93,6 +94,7 @@ class SkillResource:
description: Optional human-readable summary shown when advertising the resource.
content: Static content string. Mutually exclusive with *function*.
function: Callable (sync or async) that returns content on demand.
+ May return any type; the value is passed through as-is.
Mutually exclusive with *content*.
"""
if not name or not name.strip():
@@ -115,6 +117,110 @@ class SkillResource:
self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
+class SkillScript:
+ """An executable script attached to a skill.
+
+ .. warning:: Experimental
+
+ This API is experimental and subject to change or removal
+ in future versions without notice.
+
+ A script represents executable code that an agent can run. It holds
+ either an inline ``function`` callable (code-defined scripts) or
+ a ``path`` to a script file on disk (file-based scripts).
+ Exactly one must be provided.
+
+ When ``function`` is set the script is treated as **code-based**
+ and the function is invoked directly in-process. When ``path`` is
+ set the script is treated as **file-based** and delegated to the
+ configured :class:`SkillScriptRunner`.
+
+ Attributes:
+ name: Script identifier.
+ description: Optional human-readable summary, or ``None``.
+ function: Callable that implements the script, or ``None``.
+ path: Relative path to the script file from the skill directory, or
+ ``None`` for code-defined scripts.
+
+ Examples:
+ Code-defined script:
+
+ .. code-block:: python
+
+ SkillScript(name="analyze", function=analyze_data, description="Run analysis")
+
+ File-based script (discovered from disk):
+
+ .. code-block:: python
+
+ SkillScript(name="process.py", path="scripts/process.py")
+ """
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ description: str | None = None,
+ function: Callable[..., Any] | None = None,
+ path: str | None = None,
+ ) -> None:
+ """Initialize a SkillScript.
+
+ Args:
+ name: Identifier for this script (e.g. ``"analyze"``, ``"process.py"``).
+ description: Optional human-readable summary.
+ function: Callable (sync or async) that implements the script.
+ Set for code-defined scripts; ``None`` for file-based scripts.
+ Mutually exclusive with *path*.
+ path: Relative path to the script file from the skill directory.
+ Set automatically for file-based scripts discovered from disk;
+ ``None`` for code-defined scripts.
+ Mutually exclusive with *function*.
+ """
+ if not name or not name.strip():
+ raise ValueError("Script name cannot be empty.")
+ if function is None and path is None:
+ raise ValueError(f"Script '{name}' must have either function or path.")
+ if function is not None and path is not None:
+ raise ValueError(f"Script '{name}' must have either function or path, not both.")
+
+ self.name = name
+ self.description = description
+ self.function = function
+ self.path = path
+ self._parameters_schema: dict[str, Any] | None = None
+ self._parameters_schema_resolved: bool = False
+
+ # Precompute whether the function accepts **kwargs to avoid
+ # repeated inspect.signature() calls on every invocation.
+ self._accepts_kwargs: bool = False
+ if function is not None:
+ sig = inspect.signature(function)
+ self._accepts_kwargs = any(
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
+ )
+
+ @property
+ def parameters_schema(self) -> dict[str, Any] | None:
+ """JSON Schema describing the script's parameters.
+
+ .. warning:: Experimental
+
+ This API is experimental and subject to change or removal
+ in future versions without notice.
+
+ Lazily generated from the callable's signature on first access.
+ Returns ``None`` for file-based scripts or functions with no
+ introspectable parameters.
+ """
+ if not self._parameters_schema_resolved and self.function is not None:
+ tool = FunctionTool(name=self.function.__name__, func=self.function)
+ schema = tool.parameters()
+ self._parameters_schema = schema if schema and schema.get("properties") else None
+ self._parameters_schema_resolved = True
+ return self._parameters_schema
+
+
class Skill:
"""A skill definition with optional resources.
@@ -124,15 +230,16 @@ class Skill:
in future versions without notice.
A skill bundles a set of instructions (``content``) with metadata and
- zero or more :class:`SkillResource` instances. Resources can be
- supplied at construction time or added later via the :meth:`resource`
- decorator.
+ zero or more :class:`SkillResource` and :class:`SkillScript` instances.
+ Resources and scripts can be supplied at construction time or added later
+ via the :meth:`resource` and :meth:`script` decorators.
Attributes:
name: Skill name (lowercase letters, numbers, hyphens only).
description: Human-readable description of the skill.
content: The skill instructions body.
resources: Mutable list of :class:`SkillResource` instances.
+ scripts: Mutable list of :class:`SkillScript` instances.
path: Absolute path to the skill directory on disk, or ``None``
for code-defined skills.
@@ -171,6 +278,7 @@ class Skill:
description: str,
content: str,
resources: list[SkillResource] | None = None,
+ scripts: list[SkillScript] | None = None,
path: str | None = None,
) -> None:
"""Initialize a Skill.
@@ -180,6 +288,7 @@ class Skill:
description: Human-readable description of the skill (≤1024 chars).
content: The skill instructions body.
resources: Pre-built resources to attach to this skill.
+ scripts: Pre-built scripts to attach to this skill.
path: Absolute path to the skill directory on disk. Set automatically
for file-based skills; leave as ``None`` for code-defined skills.
"""
@@ -192,6 +301,7 @@ class Skill:
self.description = description
self.content = content
self.resources: list[SkillResource] = resources if resources is not None else []
+ self.scripts: list[SkillScript] = scripts if scripts is not None else []
self.path = path
def resource(
@@ -227,7 +337,7 @@ class Skill:
.. code-block:: python
@skill.resource
- def get_schema() -> str:
+ def get_schema() -> Any:
return "schema..."
With arguments:
@@ -235,7 +345,7 @@ class Skill:
.. code-block:: python
@skill.resource(name="custom-name", description="Custom desc")
- async def get_data() -> str:
+ async def get_data() -> Any:
return "data..."
"""
@@ -255,10 +365,118 @@ class Skill:
return decorator
return decorator(func)
+ def script(
+ self,
+ func: Callable[..., Any] | None = None,
+ *,
+ name: str | None = None,
+ description: str | None = None,
+ ) -> Any:
+ """Decorator that registers a callable as a script on this skill.
+
+ Supports bare usage (``@skill.script``) and parameterized usage
+ (``@skill.script(name="custom", description="...")``). The
+ decorated function is returned unchanged; a new
+ :class:`SkillScript` is appended to :attr:`scripts`.
+
+ Args:
+ func: The function being decorated. Populated automatically when
+ the decorator is applied without parentheses.
+
+ Keyword Args:
+ name: Script name override. Defaults to ``func.__name__``.
+ description: Script description override. Defaults to the
+ function's docstring (via :func:`inspect.getdoc`).
+
+ Returns:
+ The original function unchanged, or a secondary decorator when
+ called with keyword arguments.
+
+ Examples:
+ Bare decorator:
+
+ .. code-block:: python
+
+ @skill.script
+ def analyze_data(query: str) -> str:
+ \"\"\"Run data analysis.\"\"\"
+ return run_analysis(query)
+
+ With arguments:
+
+ .. code-block:: python
+
+ @skill.script(name="fetch", description="Fetch remote data")
+ async def fetch_data(url: str) -> str:
+ return await http_get(url)
+ """
+
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
+ script_name = name or f.__name__
+ script_description = description or (inspect.getdoc(f) or None)
+ self.scripts.append(
+ SkillScript(
+ name=script_name,
+ description=script_description,
+ function=f,
+ )
+ )
+ return f
+
+ if func is None:
+ return decorator
+ return decorator(func)
+
# endregion
-# region Constants
+# region Script Runners
+
+
+@runtime_checkable
+class SkillScriptRunner(Protocol):
+ """Protocol for skill script runners.
+
+ .. warning:: Experimental
+
+ This API is experimental and subject to change or removal
+ in future versions without notice.
+
+ A script runner determines how **file-based** skill scripts are
+ run. Implementations decide the execution strategy
+ (e.g., local subprocess, hosted code execution environment,
+ user-provided callable).
+
+ Code-defined scripts (registered via the ``@skill.script`` decorator)
+ are always executed **in-process** and do not use a script runner.
+
+ Any callable (sync or async) matching the ``__call__`` signature
+ satisfies this protocol.
+ """
+
+ def __call__(
+ self, skill: Skill, script: SkillScript, args: dict[str, Any] | None = None
+ ) -> Any:
+ """Run a skill script.
+
+ The :class:`SkillsProvider` resolves skill and script names
+ before calling this method, so implementations receive fully
+ resolved objects.
+
+ Args:
+ skill: The skill that owns the script.
+ script: The script to run.
+ args: Optional keyword arguments for the script.
+
+ Returns:
+ The result. May be any type; the framework
+ serialises it automatically via
+ :meth:`~FunctionTool.parse_result`.
+ """
+ ...
+
+
+# endregion
SKILL_FILE_NAME: Final[str] = "SKILL.md"
MAX_SEARCH_DEPTH: Final[int] = 2
@@ -273,8 +491,7 @@ DEFAULT_RESOURCE_EXTENSIONS: Final[tuple[str, ...]] = (
".xml",
".txt",
)
-
-# endregion
+DEFAULT_SCRIPT_EXTENSIONS: Final[tuple[str, ...]] = (".py",)
# region Patterns and prompt template
@@ -307,13 +524,19 @@ Each skill provides specialized instructions, reference documents, and assets fo
When a task aligns with a skill's domain, follow these steps in exact order:
-1. Use `load_skill` to retrieve the skill's instructions.
-2. Follow the provided guidance.
-3. Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
+- Use `load_skill` to retrieve the skill's instructions.
+- Follow the provided guidance.
+- Use `read_skill_resource` to read any referenced resources, using the name exactly as listed
(e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`).
-
+{runner_instructions}
Only load what is needed, when it is needed."""
+SCRIPT_RUNNER_INSTRUCTIONS: Final[str] = (
+ "\n- Use `run_skill_script` to run referenced scripts, using the name exactly as listed."
+ "\n- Pass script arguments inside `args` as a JSON object"
+ ' (e.g. `args: {"length": 24}`), not as top-level tool parameters.\n'
+)
+
# endregion
# region SkillsProvider
@@ -381,8 +604,11 @@ class SkillsProvider(BaseContextProvider):
skill_paths: str | Path | Sequence[str | Path] | None = None,
*,
skills: Sequence[Skill] | None = None,
+ script_runner: SkillScriptRunner | None = None,
instruction_template: str | None = None,
resource_extensions: tuple[str, ...] | None = None,
+ script_extensions: tuple[str, ...] | None = None,
+ require_script_approval: bool = False,
source_id: str | None = None,
) -> None:
"""Initialize a SkillsProvider.
@@ -395,21 +621,69 @@ class SkillsProvider(BaseContextProvider):
Keyword Args:
skills: Code-defined :class:`Skill` instances to register.
+ script_runner: Strategy for running **file-based** skill
+ scripts. The provider resolves skill and script names, then
+ calls the runner directly. This parameter only
+ affects scripts discovered from disk (via *skill_paths*);
+ code-defined scripts (registered with ``@skill.script``) are
+ always executed in-process and ignore this setting.
+ When ``None``, file-based scripts are not executable.
instruction_template: Custom system-prompt template for
advertising skills. Must contain a ``{skills}`` placeholder for the
generated skills list. Uses a built-in template when ``None``.
resource_extensions: File extensions recognized as discoverable
resources. Defaults to ``DEFAULT_RESOURCE_EXTENSIONS``
(``(".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt")``).
+ script_extensions: File extensions recognized as discoverable
+ scripts. Defaults to ``DEFAULT_SCRIPT_EXTENSIONS``
+ (``(".py",)``).
+ require_script_approval: When ``True``, skill script execution
+ requires explicit user approval before running. Instead of
+ executing immediately, the agent pauses and returns a
+ ``function_approval_request`` via ``result.user_input_requests``.
+ The application should present the request to the user, then
+ call ``request.to_function_approval_response(approved=True)``
+ (or ``False`` to reject) and pass the response back with
+ ``agent.run(approval_response, session=session)``.
+ Rejected scripts are not executed and the agent is informed
+ the user declined. Defaults to ``False``. See
+ ``samples/02-agents/skills/script_approval/script_approval.py``
+ for the full approval loop pattern.
source_id: Unique identifier for this provider instance.
"""
super().__init__(source_id or self.DEFAULT_SOURCE_ID)
- self._skills = _load_skills(skill_paths, skills, resource_extensions or DEFAULT_RESOURCE_EXTENSIONS)
+ self._skills = _load_skills(
+ skill_paths,
+ skills,
+ resource_extensions or DEFAULT_RESOURCE_EXTENSIONS,
+ script_extensions or DEFAULT_SCRIPT_EXTENSIONS,
+ )
- self._instructions = _create_instructions(instruction_template, self._skills)
+ # File-based skills (skill.path set) have scripts discovered from disk
+ has_file_scripts = any(s.scripts for s in self._skills.values() if s.path is not None)
- self._tools = self._create_tools()
+ # Code-defined skills (skill.path is None) have scripts with callable functions
+ has_code_scripts = any(s.scripts for s in self._skills.values() if s.path is None)
+
+ if has_file_scripts and script_runner is None:
+ raise ValueError(
+ "File-based skills with scripts were provided but no 'script_runner' was provided. "
+ "Pass a SkillScriptRunner callable to SkillsProvider."
+ )
+
+ self._script_runner = script_runner
+
+ self._instructions = _create_instructions(
+ prompt_template=instruction_template,
+ skills=self._skills,
+ include_script_runner_instructions=has_file_scripts or has_code_scripts
+ )
+
+ self._tools = self._create_tools(
+ include_script_runner_tool=has_file_scripts or has_code_scripts,
+ require_script_approval=require_script_approval,
+ )
async def before_run(
self,
@@ -425,6 +699,11 @@ class SkillsProvider(BaseContextProvider):
skill is registered, appends the skill-list system prompt and the
``load_skill`` / ``read_skill_resource`` tools to *context*.
+ When any registered skill defines one or more scripts (file-based or
+ code-based), the system prompt also includes script-runner
+ instructions (embedded via the ``{runner_instructions}`` placeholder),
+ and the ``run_skill_script`` tool is included alongside the base tools.
+
Args:
agent: The agent instance about to run.
session: The current agent session.
@@ -434,17 +713,30 @@ class SkillsProvider(BaseContextProvider):
if not self._skills:
return
- if self._instructions:
- context.extend_instructions(self.source_id, self._instructions)
+ context.extend_instructions(self.source_id, self._instructions) # type: ignore[arg-type]
context.extend_tools(self.source_id, self._tools)
- def _create_tools(self) -> list[FunctionTool]:
+ def _create_tools(
+ self,
+ include_script_runner_tool: bool,
+ require_script_approval: bool = False,
+ ) -> list[FunctionTool]:
"""Create the ``load_skill`` and ``read_skill_resource`` tool definitions.
+ When *include_script_runner_tool* is ``True``, also creates
+ ``run_skill_script``.
+
+ Args:
+ include_script_runner_tool: Whether to include the
+ ``run_skill_script`` tool in the returned list.
+ require_script_approval: When ``True``, the
+ ``run_skill_script`` tool pauses for user approval
+ before each invocation.
+
Returns:
- A two-element list of :class:`FunctionTool` instances.
+ A list of :class:`FunctionTool` instances.
"""
- return [
+ tools = [
FunctionTool(
name="load_skill",
description="Loads the full instructions for a specific skill.",
@@ -475,6 +767,45 @@ class SkillsProvider(BaseContextProvider):
),
]
+ if include_script_runner_tool:
+ tools.append(
+ FunctionTool(
+ name="run_skill_script",
+ description="Runs a script associated with a skill.",
+ func=self._run_skill_script,
+ approval_mode="always_require" if require_script_approval else "never_require",
+ input_model={
+ "type": "object",
+ "properties": {
+ "skill_name": {"type": "string", "description": "The name of the skill."},
+ "script_name": {
+ "type": "string",
+ "description": (
+ "The name of the script to run as listed in the skill, "
+ "preserving any directory prefix exactly as shown. "
+ "Do not add or remove path prefixes."
+ ),
+ },
+ "args": {
+ "type": ["object", "null"],
+ "additionalProperties": True,
+ "default": None,
+ "description": (
+ "Arguments to pass to the script as key-value pairs. "
+ "Use parameter names as keys without leading dashes "
+ '(e.g. {"length": 24, "uppercase": true}). '
+ "How these values are mapped to the underlying script "
+ "is determined by the script implementation or configured runner."
+ ),
+ },
+ },
+ "required": ["skill_name", "script_name"],
+ },
+ )
+ )
+
+ return tools
+
def _load_skill(self, skill_name: str) -> str:
"""Return the full instructions for the named skill.
@@ -516,9 +847,79 @@ class SkillsProvider(BaseContextProvider):
resource_lines = "\n".join(_create_resource_element(r) for r in skill.resources)
content += f"\n\n\n{resource_lines}\n"
+ if skill.scripts:
+ script_lines = "\n".join(_create_script_element(s) for s in skill.scripts)
+ content += f"\n\n\n{script_lines}\n"
+
return content
- async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwargs: Any) -> str:
+ async def _run_skill_script(
+ self, skill_name: str, script_name: str, args: dict[str, Any] | None = None, **kwargs: Any
+ ) -> Any:
+ """Run a named script from a skill.
+
+ For code-defined scripts (those with a ``function`` and no ``path``),
+ the function is invoked directly in-process. For file-based scripts
+ the configured :class:`SkillScriptRunner` is used.
+
+ Args:
+ skill_name: The name of the owning skill.
+ script_name: The script name to look up (case-insensitive).
+ args: Optional keyword arguments for the script, provided by the
+ agent/LLM. These are mapped to the function's declared
+ parameters.
+ **kwargs: Runtime keyword arguments forwarded only to script
+ functions that accept ``**kwargs`` (e.g. arguments passed via
+ ``agent.run(user_id="123")``).
+
+ Returns:
+ The result, or a user-facing error message on
+ failure.
+ """
+ if not skill_name or not skill_name.strip():
+ return "Error: Skill name cannot be empty."
+
+ if not script_name or not script_name.strip():
+ return "Error: Script name cannot be empty."
+
+ skill = self._skills.get(skill_name)
+ if not skill:
+ return f"Error: Skill '{skill_name}' not found."
+
+ script = next((s for s in skill.scripts if s.name.lower() == script_name.lower()), None)
+ if not script:
+ return f"Error: Script '{script_name}' not found in skill '{skill_name}'."
+
+ # Code-defined scripts: run the function directly
+ if script.function is not None:
+ try:
+ if script._accepts_kwargs: # pyright: ignore[reportPrivateUsage]
+ result = script.function(**(args or {}), **kwargs)
+ else:
+ result = script.function(**(args or {}))
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+ except Exception:
+ logger.exception("Error running code-defined script '%s' in skill '%s'", script_name, skill_name)
+ return f"Error: Failed to run script '{script_name}' in skill '{skill_name}'."
+
+ # File-based scripts: delegate to the runner
+ if self._script_runner is None:
+ return (
+ f"Error: Script '{script_name}' in skill '{skill_name}' requires a runner. "
+ "Provide a script_runner for file-based scripts."
+ )
+ try:
+ result = self._script_runner(skill, script, args)
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+ except Exception:
+ logger.exception("Error running file-based script '%s' in skill '%s'", script_name, skill_name)
+ return f"Error: Failed to run script '{script_name}' in skill '{skill_name}'."
+
+ async def _read_skill_resource(self, skill_name: str, resource_name: str, **kwargs: Any) -> Any:
"""Read a named resource from a skill.
Resolves the resource by case-insensitive name lookup. Static
@@ -533,7 +934,7 @@ class SkillsProvider(BaseContextProvider):
``agent.run(user_id="123")``).
Returns:
- The resource content string, or a user-facing error message on
+ The resource content (any type), or a user-facing error message on
failure.
"""
if not skill_name or not skill_name.strip():
@@ -565,13 +966,10 @@ class SkillsProvider(BaseContextProvider):
)
else:
result = resource.function(**kwargs) if resource._accepts_kwargs else resource.function() # pyright: ignore[reportPrivateUsage]
- return str(result)
- except Exception as exc:
+ return result
+ except Exception:
logger.exception("Failed to read resource '%s' from skill '%s'", resource_name, skill_name)
- return (
- f"Error ({type(exc).__name__}): Failed to read resource"
- f" '{resource_name}' from skill '{skill_name}'."
- )
+ return f"Error: Failed to read resource '{resource_name}' from skill '{skill_name}'."
return f"Error: Resource '{resource.name}' has no content or function."
@@ -707,6 +1105,60 @@ def _discover_resource_files(
return resources
+def _discover_script_files(
+ skill_dir_path: str,
+ extensions: tuple[str, ...] = DEFAULT_SCRIPT_EXTENSIONS,
+) -> list[str]:
+ """Scan a skill directory for script files matching *extensions*.
+
+ Recursively walks *skill_dir_path* and collects files whose extension
+ is in *extensions*. Each candidate is validated against path-traversal
+ and symlink-escape checks; unsafe files are skipped with a warning.
+
+ Args:
+ skill_dir_path: Absolute path to the skill directory to scan.
+ extensions: Tuple of allowed script extensions (e.g. ``(".py",)``).
+
+ Returns:
+ Relative script paths (forward-slash-separated) for every
+ discovered file that passes security checks.
+ """
+ skill_dir = Path(skill_dir_path).absolute()
+ root_directory_path = str(skill_dir)
+ scripts: list[str] = []
+ normalized_extensions = {e.lower() for e in extensions}
+
+ for script_file in skill_dir.rglob("*"):
+ if not script_file.is_file():
+ continue
+
+ if script_file.suffix.lower() not in normalized_extensions:
+ continue
+
+ script_full_path = str(Path(os.path.normpath(script_file)).absolute())
+
+ if not _is_path_within_directory(script_full_path, root_directory_path):
+ logger.warning(
+ "Skipping script '%s': resolves outside skill directory '%s'",
+ script_file,
+ skill_dir_path,
+ )
+ continue
+
+ if _has_symlink_in_path(script_full_path, root_directory_path):
+ logger.warning(
+ "Skipping script '%s': symlink detected in path under skill directory '%s'",
+ script_file,
+ skill_dir_path,
+ )
+ continue
+
+ rel_path = script_file.relative_to(skill_dir)
+ scripts.append(_normalize_resource_path(str(rel_path)))
+
+ return scripts
+
+
def _validate_skill_metadata(
name: str | None,
description: str | None,
@@ -902,6 +1354,7 @@ def _read_file_skill_resource(skill: Skill, resource_name: str) -> str:
def _discover_file_skills(
skill_paths: str | Path | Sequence[str | Path] | None,
resource_extensions: tuple[str, ...] = DEFAULT_RESOURCE_EXTENSIONS,
+ script_extensions: tuple[str, ...] = DEFAULT_SCRIPT_EXTENSIONS,
) -> dict[str, Skill]:
"""Discover, parse, and load all file-based skills from the given paths.
@@ -912,6 +1365,7 @@ def _discover_file_skills(
Args:
skill_paths: Directory path(s) to scan, or ``None`` to skip.
resource_extensions: File extensions recognized as resources.
+ script_extensions: File extensions recognized as scripts.
Returns:
A dict mapping skill name → :class:`Skill`.
@@ -955,6 +1409,10 @@ def _discover_file_skills(
reader = (lambda s, r: lambda: _read_file_skill_resource(s, r))(file_skill, rn)
file_skill.resources.append(SkillResource(name=rn, function=reader))
+ # Discover and attach file-based scripts as SkillScript instances
+ for sn in _discover_script_files(skill_path, script_extensions):
+ file_skill.scripts.append(SkillScript(name=sn, path=sn))
+
skills[file_skill.name] = file_skill
logger.info("Loaded skill: %s", file_skill.name)
@@ -966,6 +1424,7 @@ def _load_skills(
skill_paths: str | Path | Sequence[str | Path] | None,
skills: Sequence[Skill] | None,
resource_extensions: tuple[str, ...],
+ script_extensions: tuple[str, ...],
) -> dict[str, Skill]:
"""Discover and merge skills from file paths and code-defined skills.
@@ -977,11 +1436,12 @@ def _load_skills(
skill_paths: Directory path(s) to scan for ``SKILL.md`` files, or ``None``.
skills: Code-defined :class:`Skill` instances, or ``None``.
resource_extensions: File extensions recognized as discoverable resources.
+ script_extensions: File extensions recognized as discoverable scripts.
Returns:
A dict mapping skill name → :class:`Skill`.
"""
- result = _discover_file_skills(skill_paths, resource_extensions)
+ result = _discover_file_skills(skill_paths, resource_extensions, script_extensions)
if skills:
for code_skill in skills:
@@ -1017,19 +1477,50 @@ def _create_resource_element(resource: SkillResource) -> str:
return f" "
+def _create_script_element(script: SkillScript) -> str:
+ """Create an XML ``"
+ return f" "
+
+
def _create_instructions(
prompt_template: str | None,
skills: dict[str, Skill],
+ include_script_runner_instructions: bool = False,
) -> str | None:
"""Create the system-prompt text that advertises available skills.
Generates an XML list of ```` elements (sorted by name) and
inserts it into *prompt_template* at the ``{skills}`` placeholder.
+ When *include_script_runner_instructions* is ``True``, executor-provided
+ instructions are inserted at the ``{runner_instructions}`` placeholder.
Args:
- prompt_template: Custom template string with a ``{skills}`` placeholder,
+ prompt_template: Custom template string with ``{skills}`` and
+ optional ``{runner_instructions}`` placeholders,
or ``None`` to use the built-in default.
skills: Registered skills keyed by name.
+ include_script_runner_instructions: When ``True``, include
+ script-runner instructions in the generated prompt.
+ Defaults to ``False``.
Returns:
The formatted instruction string, or ``None`` when *skills* is empty.
@@ -1038,12 +1529,13 @@ def _create_instructions(
ValueError: If *prompt_template* is not a valid format string
(e.g. missing ``{skills}`` placeholder).
"""
+ runner_instructions = SCRIPT_RUNNER_INSTRUCTIONS if include_script_runner_instructions else None
template = DEFAULT_SKILLS_INSTRUCTION_PROMPT
if prompt_template is not None:
# Validate that the custom template contains a valid {skills} placeholder
try:
- result = prompt_template.format(skills="__PROBE__")
+ result = prompt_template.format(skills="__PROBE__", runner_instructions="__EXEC_PROBE__")
except (KeyError, IndexError, ValueError) as exc:
raise ValueError(
"The provided instruction_template is not a valid format string. "
@@ -1055,6 +1547,11 @@ def _create_instructions(
raise ValueError(
"The provided instruction_template must contain a '{skills}' placeholder." # noqa: RUF027
)
+ if runner_instructions and "__EXEC_PROBE__" not in result:
+ raise ValueError(
+ "The provided instruction_template must contain an '{runner_instructions}' placeholder " # noqa: RUF027
+ "when a script runner is configured."
+ )
template = prompt_template
if not skills:
@@ -1068,7 +1565,10 @@ def _create_instructions(
lines.append(f" {xml_escape(skill.description)}")
lines.append(" ")
- return template.format(skills="\n".join(lines))
+ return template.format(
+ skills="\n".join(lines),
+ runner_instructions=runner_instructions or "",
+ )
# endregion
diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py
index cb829b7b9f..8fe941b208 100644
--- a/python/packages/core/tests/core/test_skills.py
+++ b/python/packages/core/tests/core/test_skills.py
@@ -14,14 +14,18 @@ import pytest
from agent_framework import SessionContext, Skill, SkillResource, SkillsProvider
from agent_framework._skills import (
DEFAULT_RESOURCE_EXTENSIONS,
+ DEFAULT_SCRIPT_EXTENSIONS,
_create_instructions,
_create_resource_element,
+ _create_script_element,
_discover_file_skills,
_discover_resource_files,
+ _discover_script_files,
_discover_skill_directories,
_extract_frontmatter,
_has_symlink_in_path,
_is_path_within_directory,
+ _load_skills,
_normalize_resource_path,
_read_and_parse_skill_file,
_read_file_skill_resource,
@@ -29,6 +33,11 @@ from agent_framework._skills import (
)
+async def _noop_script_runner(skill: Any, script: Any, args: Any = None) -> None:
+ """No-op script runner for tests that need a SkillScriptRunner."""
+ return None
+
+
def _symlinks_supported(tmp: Path) -> bool:
"""Return True if the current platform/environment supports symlinks."""
test_target = tmp / "_symlink_test_target"
@@ -742,6 +751,27 @@ class TestSymlinkDetection:
with pytest.raises(ValueError, match="symlink"):
_read_file_skill_resource(skill, "refs/leak.md")
+ def test_discover_skips_symlinked_script(self, tmp_path: Path) -> None:
+ """_discover_script_files should skip scripts with symlinks in their path."""
+ if not _symlinks_supported(tmp_path):
+ pytest.skip("Symlinks not supported on this platform/environment")
+
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+
+ outside_script = tmp_path / "evil.py"
+ outside_script.write_text("print('evil')", encoding="utf-8")
+
+ scripts_dir = skill_dir / "scripts"
+ scripts_dir.mkdir()
+ (scripts_dir / "safe.py").write_text("print('safe')", encoding="utf-8")
+ (scripts_dir / "leak.py").symlink_to(outside_script)
+
+ discovered = _discover_script_files(str(skill_dir))
+ discovered_names = [p for p in discovered]
+ assert "scripts/safe.py" in discovered_names
+ assert "scripts/leak.py" not in discovered_names
+
# ---------------------------------------------------------------------------
# Tests: SkillResource
@@ -778,6 +808,20 @@ class TestSkillResource:
with pytest.raises(ValueError, match="must have either content or function, not both"):
SkillResource(name="both", content="static", function=lambda: "dynamic")
+ def test_accepts_kwargs_true_for_kwargs_function(self) -> None:
+ def func_with_kwargs(**kwargs: Any) -> str:
+ return "dynamic"
+
+ resource = SkillResource(name="res", function=func_with_kwargs)
+ assert resource._accepts_kwargs is True
+
+ def test_accepts_kwargs_false_for_regular_function(self) -> None:
+ def func_no_kwargs() -> str:
+ return "dynamic"
+
+ resource = SkillResource(name="res", function=func_no_kwargs)
+ assert resource._accepts_kwargs is False
+
# ---------------------------------------------------------------------------
# Tests: Skill
@@ -838,7 +882,7 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def get_schema() -> str:
+ def get_schema() -> Any:
"""Get the database schema."""
return "CREATE TABLE users (id INT)"
@@ -851,7 +895,7 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource(name="custom-name", description="Custom description")
- def my_resource() -> str:
+ def my_resource() -> Any:
return "data"
assert len(skill.resources) == 1
@@ -863,7 +907,7 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def get_data() -> str:
+ def get_data() -> Any:
return "data"
assert callable(get_data)
@@ -873,11 +917,11 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def resource_a() -> str:
+ def resource_a() -> Any:
return "A"
@skill.resource
- def resource_b() -> str:
+ def resource_b() -> Any:
return "B"
assert len(skill.resources) == 2
@@ -889,7 +933,7 @@ class TestSkill:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- async def get_async_data() -> str:
+ async def get_async_data() -> Any:
return "async data"
assert len(skill.resources) == 1
@@ -959,7 +1003,7 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- def get_schema() -> str:
+ def get_schema() -> Any:
return "CREATE TABLE users"
provider = SkillsProvider(skills=[skill])
@@ -970,7 +1014,7 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- async def get_data() -> str:
+ async def get_data() -> Any:
return "async data"
provider = SkillsProvider(skills=[skill])
@@ -998,7 +1042,7 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- def get_user_config(**kwargs: Any) -> str:
+ def get_user_config(**kwargs: Any) -> Any:
user_id = kwargs.get("user_id", "unknown")
return f"config for {user_id}"
@@ -1010,7 +1054,7 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- async def get_user_data(**kwargs: Any) -> str:
+ async def get_user_data(**kwargs: Any) -> Any:
token = kwargs.get("auth_token", "none")
return f"data with token={token}"
@@ -1023,13 +1067,49 @@ class TestSkillsProviderCodeSkill:
skill = Skill(name="prog-skill", description="A skill.", content="Body")
@skill.resource
- def static_resource() -> str:
+ def static_resource() -> Any:
return "static content"
provider = SkillsProvider(skills=[skill])
result = await provider._read_skill_resource("prog-skill", "static_resource", user_id="ignored")
assert result == "static content"
+ async def test_read_callable_resource_returns_dict(self) -> None:
+ """Resource functions may return non-string types, passed through as-is."""
+ skill = Skill(name="prog-skill", description="A skill.", content="Body")
+
+ @skill.resource
+ def get_config() -> Any:
+ return {"max_retries": 3, "timeout": 30}
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("prog-skill", "get_config")
+ assert result == {"max_retries": 3, "timeout": 30}
+
+ async def test_read_callable_resource_returns_list(self) -> None:
+ """Resource functions may return lists, passed through as-is."""
+ skill = Skill(name="prog-skill", description="A skill.", content="Body")
+
+ @skill.resource
+ def get_items() -> Any:
+ return [1, 2, 3]
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("prog-skill", "get_items")
+ assert result == [1, 2, 3]
+
+ async def test_read_callable_resource_returns_none(self) -> None:
+ """Resource functions may return None."""
+ skill = Skill(name="prog-skill", description="A skill.", content="Body")
+
+ @skill.resource
+ def get_nothing() -> Any:
+ return None
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("prog-skill", "get_nothing")
+ assert result is None
+
async def test_before_run_injects_code_skills(self) -> None:
skill = Skill(name="prog-skill", description="A code-defined skill.", content="Body")
provider = SkillsProvider(skills=[skill])
@@ -1570,6 +1650,24 @@ class TestCreateInstructionsEdgeCases:
charlie_pos = result.index("charlie")
assert alpha_pos < bravo_pos < charlie_pos
+ def test_custom_template_missing_runner_instructions_raises(self) -> None:
+ """Custom template without {runner_instructions} raises when scripts are enabled."""
+ skills = {
+ "my-skill": Skill(name="my-skill", description="Skill.", content="Body"),
+ }
+ template = "Skills: {skills}"
+ with pytest.raises(ValueError, match="runner_instructions"):
+ _create_instructions(template, skills, include_script_runner_instructions=True)
+
+ def test_custom_template_with_unknown_placeholder_raises(self) -> None:
+ """Template with an unknown placeholder raises ValueError."""
+ skills = {
+ "my-skill": Skill(name="my-skill", description="Skill.", content="Body"),
+ }
+ template = "Skills: {skills} {unknown_key}"
+ with pytest.raises(ValueError, match="valid format string"):
+ _create_instructions(template, skills)
+
# ---------------------------------------------------------------------------
# Tests: SkillsProvider edge cases
@@ -1609,24 +1707,24 @@ class TestSkillsProviderEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def exploding_resource() -> str:
+ def exploding_resource() -> Any:
raise RuntimeError("boom")
provider = SkillsProvider(skills=[skill])
result = await provider._read_skill_resource("my-skill", "exploding_resource")
- assert result.startswith("Error (RuntimeError):")
+ assert result.startswith("Error:")
assert "Failed to read resource" in result
async def test_read_async_callable_resource_exception_returns_error(self) -> None:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- async def async_exploding() -> str:
+ async def async_exploding() -> Any:
raise ValueError("async boom")
provider = SkillsProvider(skills=[skill])
result = await provider._read_skill_resource("my-skill", "async_exploding")
- assert result.startswith("Error (ValueError):")
+ assert result.startswith("Error:")
def test_load_code_skill_xml_escapes_metadata(self) -> None:
skill = Skill(name="my-skill", description='Uses & "quotes"', content="Body")
@@ -1689,7 +1787,7 @@ class TestSkillResourceDecoratorEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def no_docs() -> str:
+ def no_docs() -> Any:
return "data"
assert skill.resources[0].description is None
@@ -1698,7 +1796,7 @@ class TestSkillResourceDecoratorEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource(name="custom-name")
- def get_data() -> str:
+ def get_data() -> Any:
"""Some docs."""
return "data"
@@ -1710,7 +1808,7 @@ class TestSkillResourceDecoratorEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource(description="Custom desc")
- def get_data() -> str:
+ def get_data() -> Any:
return "data"
assert skill.resources[0].name == "get_data"
@@ -1720,13 +1818,1289 @@ class TestSkillResourceDecoratorEdgeCases:
skill = Skill(name="my-skill", description="A skill.", content="Body")
@skill.resource
- def original() -> str:
+ def original() -> Any:
return "original"
@skill.resource(name="aliased")
- def aliased() -> str:
+ def aliased() -> Any:
return "aliased"
# Both decorated functions should still be callable
assert original() == "original"
assert aliased() == "aliased"
+
+
+# ---------------------------------------------------------------------------
+# SkillScript tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillScript:
+ """Tests for the SkillScript data model."""
+
+ def test_empty_name_raises(self) -> None:
+ from agent_framework import SkillScript
+
+ with pytest.raises(ValueError, match="Script name cannot be empty"):
+ SkillScript(name="")
+
+ def test_whitespace_name_raises(self) -> None:
+ from agent_framework import SkillScript
+
+ with pytest.raises(ValueError, match="Script name cannot be empty"):
+ SkillScript(name=" ")
+
+ def test_path_default_none(self) -> None:
+ from agent_framework import SkillScript
+
+ script = SkillScript(name="test", function=lambda: None)
+ assert script.path is None
+
+ def test_path_set_explicitly(self) -> None:
+ from agent_framework import SkillScript
+
+ script = SkillScript(name="gen.py", path="/skills/my-skill/scripts/gen.py")
+ assert script.path == "/skills/my-skill/scripts/gen.py"
+
+ def test_create_with_function(self) -> None:
+ from agent_framework import SkillScript
+
+ script = SkillScript(name="analyze", description="Run analysis", function=lambda: "result")
+ assert script.name == "analyze"
+ assert script.description == "Run analysis"
+ assert script.function is not None
+
+ def test_accepts_kwargs_true_for_kwargs_function(self) -> None:
+ from agent_framework import SkillScript
+
+ def func_with_kwargs(**kwargs: Any) -> str:
+ return "result"
+
+ script = SkillScript(name="s1", function=func_with_kwargs)
+ assert script._accepts_kwargs is True
+
+ def test_accepts_kwargs_false_for_regular_function(self) -> None:
+ from agent_framework import SkillScript
+
+ def func_no_kwargs(x: int = 0) -> str:
+ return "result"
+
+ script = SkillScript(name="s1", function=func_no_kwargs)
+ assert script._accepts_kwargs is False
+
+
+# ---------------------------------------------------------------------------
+# @skill.script decorator tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillScriptDecorator:
+ """Tests for the @skill.script decorator."""
+
+ def test_bare_decorator(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def analyze(query: str) -> str:
+ """Run analysis."""
+ return "result"
+
+ assert len(skill.scripts) == 1
+ assert skill.scripts[0].name == "analyze"
+ assert skill.scripts[0].description == "Run analysis."
+ assert skill.scripts[0].function is analyze
+
+ def test_parameterized_decorator(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script(name="custom-name", description="Custom desc")
+ def my_func() -> str:
+ return "data"
+
+ assert len(skill.scripts) == 1
+ assert skill.scripts[0].name == "custom-name"
+ assert skill.scripts[0].description == "Custom desc"
+ assert skill.scripts[0].function is my_func
+
+ def test_multiple_scripts(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def script_a() -> str:
+ return "a"
+
+ @skill.script
+ def script_b() -> str:
+ return "b"
+
+ assert len(skill.scripts) == 2
+ assert skill.scripts[0].name == "script_a"
+ assert skill.scripts[1].name == "script_b"
+
+ def test_async_script(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ async def fetch_data() -> str:
+ """Fetch remote data."""
+ return "data"
+
+ assert len(skill.scripts) == 1
+ assert skill.scripts[0].name == "fetch_data"
+ assert skill.scripts[0].function is fetch_data
+
+ def test_decorator_returns_original_function(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def original() -> str:
+ return "original"
+
+ @skill.script(name="aliased")
+ def aliased() -> str:
+ return "aliased"
+
+ assert original() == "original"
+ assert aliased() == "aliased"
+
+
+# ---------------------------------------------------------------------------
+# Skill with scripts attribute tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillWithScripts:
+ """Tests for the Skill class with scripts attribute."""
+
+ def test_default_empty_scripts(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ assert skill.scripts == []
+
+ def test_scripts_at_construction(self) -> None:
+ from agent_framework import SkillScript
+
+ scripts = [SkillScript(name="s1", function=lambda: None)]
+ skill = Skill(name="my-skill", description="test", content="body", scripts=scripts)
+ assert len(skill.scripts) == 1
+ assert skill.scripts[0].name == "s1"
+
+
+# ---------------------------------------------------------------------------
+# Runner tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillScriptRunnerProtocol:
+ """Tests for the SkillScriptRunner protocol."""
+
+ async def test_async_callable_satisfies_protocol(self) -> None:
+ from agent_framework import SkillScriptRunner, SkillScript
+
+ results: list[tuple] = []
+
+ async def my_runner(skill, script, args=None):
+ results.append((skill.name, script.name, args))
+ return "executed"
+
+ assert isinstance(my_runner, SkillScriptRunner)
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="my-script", path="scripts/run.py")
+ skill.scripts.append(script)
+
+ result = await my_runner(skill, script, args={"key": "val"})
+
+ assert result == "executed"
+ assert len(results) == 1
+ assert results[0] == ("test-skill", "my-script", {"key": "val"})
+
+ async def test_callable_class_satisfies_protocol(self) -> None:
+ from agent_framework import SkillScriptRunner, SkillScript
+
+ class _CustomRunner:
+ async def __call__(self, skill, script, args=None):
+ return "custom result"
+
+ runner = _CustomRunner()
+ assert isinstance(runner, SkillScriptRunner)
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="my-script", function=lambda: None)
+ skill.scripts.append(script)
+
+ result = await runner(skill, script, args={"key": "val"})
+ assert result == "custom result"
+
+ async def test_runner_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ async def noop_runner(skill, script, args=None):
+ return None
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="s1", function=lambda: None)
+
+ result = await noop_runner(skill, script)
+ assert result is None
+
+ async def test_runner_returns_object(self) -> None:
+ from agent_framework import SkillScript
+
+ async def dict_runner(skill, script, args=None):
+ return {"exit_code": 0, "output": "ok"}
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="s1", path="scripts/run.py")
+
+ result = await dict_runner(skill, script)
+ assert result == {"exit_code": 0, "output": "ok"}
+
+ def test_sync_callable_satisfies_protocol(self) -> None:
+ from agent_framework import SkillScriptRunner, SkillScript
+
+ results: list[tuple] = []
+
+ def my_runner(skill, script, args=None):
+ results.append((skill.name, script.name, args))
+ return "executed"
+
+ assert isinstance(my_runner, SkillScriptRunner)
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="my-script", path="scripts/run.py")
+ skill.scripts.append(script)
+
+ result = my_runner(skill, script, args={"key": "val"})
+
+ assert result == "executed"
+ assert len(results) == 1
+ assert results[0] == ("test-skill", "my-script", {"key": "val"})
+
+ def test_sync_callable_class_satisfies_protocol(self) -> None:
+ from agent_framework import SkillScriptRunner, SkillScript
+
+ class _SyncRunner:
+ def __call__(self, skill, script, args=None):
+ return "sync result"
+
+ runner = _SyncRunner()
+ assert isinstance(runner, SkillScriptRunner)
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="my-script", function=lambda: None)
+ skill.scripts.append(script)
+
+ result = runner(skill, script, args={"key": "val"})
+ assert result == "sync result"
+
+ def test_sync_runner_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ def noop_runner(skill, script, args=None):
+ return None
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="s1", function=lambda: None)
+
+ result = noop_runner(skill, script)
+ assert result is None
+
+ def test_sync_runner_returns_object(self) -> None:
+ from agent_framework import SkillScript
+
+ def dict_runner(skill, script, args=None):
+ return {"exit_code": 0, "output": "ok"}
+
+ skill = Skill(name="test-skill", description="test", content="body")
+ script = SkillScript(name="s1", path="scripts/run.py")
+
+ result = dict_runner(skill, script)
+ assert result == {"exit_code": 0, "output": "ok"}
+
+# ---------------------------------------------------------------------------
+# SkillsProvider static factory tests
+# ---------------------------------------------------------------------------
+
+
+class TestSkillsProviderFactories:
+ """Tests for the SkillsProvider constructor auto-wiring behavior."""
+
+ def test_code_skills_with_scripts_creates_provider(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ assert len(provider._skills) == 1
+ # Default runner auto-wired: base tools + run_skill_script
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ def test_code_skills_no_scripts(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ provider = SkillsProvider(skills=[skill])
+ # No scripts with functions, no runner — only base tools
+ assert len(provider._tools) == 2
+ assert not any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ async def test_code_script_runs_directly(self) -> None:
+ from agent_framework import SkillScript
+
+ def my_function(key: str = "") -> str:
+ return f"executed: {key}"
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=my_function))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="s1", args={"key": "hello"})
+
+ assert result == "executed: hello"
+
+ def test_no_scripts_no_tool(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ # No scripts at all — no run_skill_script tool
+ provider = SkillsProvider(skills=[skill])
+ assert not any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ def test_file_skills_with_custom_runner(self, tmp_path: Path) -> None:
+ from agent_framework import SkillScriptRunner
+
+ class _CustomRunner:
+ async def __call__(self, skill, script, args=None):
+ return "custom result"
+
+ assert isinstance(_CustomRunner(), SkillScriptRunner)
+
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ script_runner=_CustomRunner(),
+ )
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ def test_file_skills_with_sync_runner(self, tmp_path: Path) -> None:
+ from agent_framework import SkillScriptRunner
+
+ def sync_runner(skill, script, args=None):
+ return "sync result"
+
+ assert isinstance(sync_runner, SkillScriptRunner)
+
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ script_runner=sync_runner,
+ )
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ async def test_file_script_with_sync_runner_executes(self, tmp_path: Path) -> None:
+ """A sync script_runner is awaitable through the provider's run_skill_script."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ def sync_runner(skill, script, args=None):
+ return f"sync: {script.name} args={args}"
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ script_runner=sync_runner,
+ )
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="run.py", args={"key": "val"})
+ assert result == "sync: run.py args={'key': 'val'}"
+
+ def test_file_skills_with_callback_runner(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ script_runner=_noop_script_runner,
+ )
+ assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in provider._tools)
+
+ def test_combined_skills(self, tmp_path: Path) -> None:
+ from agent_framework import SkillScript
+
+ skill_dir = tmp_path / "file-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: file-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+
+ code_skill = Skill(name="code-skill", description="test", content="body")
+ code_skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(
+ skill_paths=str(tmp_path),
+ skills=[code_skill],
+ script_runner=_noop_script_runner,
+ )
+ assert "file-skill" in provider._skills
+ assert "code-skill" in provider._skills
+
+ def test_file_scripts_without_runner_raises(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8")
+
+ with pytest.raises(ValueError, match="script_runner"):
+ SkillsProvider(skill_paths=str(tmp_path))
+
+ async def test_file_script_error_without_runner(self) -> None:
+ from agent_framework import SkillScript
+
+ # A skill with both a code script and a file-based script
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="code-s", function=lambda: "ok"))
+ skill.scripts.append(SkillScript(name="file-s", path="scripts/s1.py"))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+
+ # Code script works
+ result = await run_tool.func(skill_name="my-skill", script_name="code-s")
+ assert result == "ok"
+
+ # File script without runner returns error
+ result = await run_tool.func(skill_name="my-skill", script_name="file-s")
+ assert "Error" in result
+ assert "script_runner" in result
+
+ async def test_async_code_script_runs_directly(self) -> None:
+ from agent_framework import SkillScript
+
+ async def async_func(x: int = 0) -> str:
+ return f"async: {x}"
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=async_func))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="s1", args={"x": 42})
+ assert result == "async: 42"
+
+ async def test_code_script_returns_object(self) -> None:
+ """Code-defined scripts can return non-string objects."""
+ from agent_framework import SkillScript
+
+ def returns_dict() -> dict:
+ return {"status": "ok", "value": 42}
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=returns_dict))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="s1")
+ assert result == {"status": "ok", "value": 42}
+
+ async def test_code_script_returns_none(self) -> None:
+ """Code-defined scripts returning None pass through as None."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="s1")
+ assert result is None
+
+ async def test_script_with_path_and_function_raises_error(self) -> None:
+ """A script cannot have both a path and a function."""
+ from agent_framework import SkillScript
+
+ with pytest.raises(ValueError, match="must have either function or path, not both"):
+ SkillScript(name="s1", function=lambda: "direct", path="scripts/s1.py")
+
+ async def test_script_with_path_errors_without_runner(self) -> None:
+ """A file-based script without a runner should return an error."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="code-s", function=lambda: "ok"))
+ skill.scripts.append(SkillScript(name="path-s", path="scripts/s1.py"))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+
+ # Code-only script still works
+ result = await run_tool.func(skill_name="my-skill", script_name="code-s")
+ assert result == "ok"
+
+ # Path+function script without runner returns error
+ result = await run_tool.func(skill_name="my-skill", script_name="path-s")
+ assert "Error" in result
+ assert "script_runner" in result
+
+ async def test_run_skill_script_error_on_missing_skill(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="nonexistent", script_name="s1")
+ assert "Error" in result
+ assert "nonexistent" in result
+
+ async def test_run_skill_script_sync_with_kwargs(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def greet(name: str, **kwargs: Any) -> str:
+ user_id = kwargs.get("user_id", "unknown")
+ return f"Hello {name} (user={user_id})"
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._run_skill_script("my-skill", "greet", args={"name": "Alice"}, user_id="u42")
+ assert result == "Hello Alice (user=u42)"
+
+ async def test_run_skill_script_async_with_kwargs(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ async def fetch(url: str, **kwargs: Any) -> str:
+ token = kwargs.get("auth_token", "none")
+ return f"fetched {url} with token={token}"
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._run_skill_script("my-skill", "fetch", args={"url": "http://x"}, auth_token="abc")
+ assert result == "fetched http://x with token=abc"
+
+ async def test_run_skill_script_without_kwargs_ignores_extra_args(self) -> None:
+ """Script functions without **kwargs should still work when runtime kwargs are passed."""
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def simple(query: str) -> str:
+ return f"result: {query}"
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._run_skill_script("my-skill", "simple", args={"query": "test"}, user_id="ignored")
+ assert result == "result: test"
+
+ async def test_run_skill_script_conflicting_args_and_kwargs_raises(self) -> None:
+ """Conflicting keys in args and kwargs should raise TypeError."""
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ @skill.script
+ def process(**kwargs: Any) -> str:
+ return f"mode={kwargs.get('mode', 'default')}"
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._run_skill_script(
+ "my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value"
+ )
+ assert "Error" in result
+
+ async def test_run_skill_script_error_on_missing_script(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="nonexistent")
+ assert "Error" in result
+ assert "nonexistent" in result
+
+ async def test_run_skill_script_error_on_empty_names(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+
+ result = await run_tool.func(skill_name="", script_name="s1")
+ assert "Error" in result
+
+ result = await run_tool.func(skill_name="my-skill", script_name="")
+ assert "Error" in result
+
+ def test_instructions_include_script_runner_hints(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ assert "run_skill_script" in provider._instructions
+ assert "not as top-level tool parameters" in provider._instructions
+
+ def test_no_scripts_no_runner_no_script_instructions(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ provider = SkillsProvider(skills=[skill])
+ # No scripts and no runner — instructions should not mention run_skill_script
+ assert "run_skill_script" not in (provider._instructions or "")
+
+ def test_tool_schema_args_description_mentions_key_format(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ args_desc = run_tool.parameters()["properties"]["args"]["description"]
+ assert "without leading dashes" in args_desc
+ assert "script implementation or configured runner" in args_desc
+
+ def test_require_script_approval_sets_approval_mode(self) -> None:
+ """When require_script_approval=True, the run_skill_script tool has approval_mode='always_require'."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill], require_script_approval=True)
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ assert run_tool.approval_mode == "always_require"
+
+ def test_require_script_approval_false_by_default(self) -> None:
+ """By default, the run_skill_script tool has approval_mode='never_require'."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ assert run_tool.approval_mode == "never_require"
+
+ def test_require_script_approval_does_not_affect_other_tools(self) -> None:
+ """The load_skill and read_skill_resource tools should never require approval."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill], require_script_approval=True)
+ other_tools = [t for t in provider._tools if hasattr(t, "name") and t.name != "run_skill_script"]
+ assert len(other_tools) == 2
+ for t in other_tools:
+ assert t.approval_mode == "never_require"
+
+ async def test_code_script_exception_returns_error(self) -> None:
+ """A code script function that raises should return an error string."""
+ from agent_framework import SkillScript
+
+ def failing_script() -> str:
+ raise RuntimeError("Something went wrong")
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="boom", function=failing_script))
+
+ provider = SkillsProvider(skills=[skill])
+ run_tool = next(t for t in provider._tools if hasattr(t, "name") and t.name == "run_skill_script")
+ result = await run_tool.func(skill_name="my-skill", script_name="boom")
+ assert "Error" in result
+ assert "boom" in result
+ assert "Something went wrong" not in result
+
+ def test_custom_template_without_runner_placeholder_raises(self) -> None:
+ """Provider with code scripts and custom template missing {runner_instructions} raises."""
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ with pytest.raises(ValueError, match="runner_instructions"):
+ SkillsProvider(
+ skills=[skill],
+ instruction_template="Skills: {skills}",
+ )
+
+
+# ---------------------------------------------------------------------------
+# File script discovery tests
+# ---------------------------------------------------------------------------
+
+
+class TestFileScriptDiscovery:
+ """Tests for automatic .py script discovery in skill directories."""
+
+ def test_discovers_py_files(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8")
+
+ skills = _discover_file_skills(str(tmp_path))
+ assert "my-skill" in skills
+ assert len(skills["my-skill"].scripts) == 1
+ assert skills["my-skill"].scripts[0].name == "analyze.py"
+
+ def test_discovered_script_has_relative_path(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ scripts_dir = skill_dir / "scripts"
+ scripts_dir.mkdir(parents=True)
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (scripts_dir / "generate.py").write_text("print('gen')", encoding="utf-8")
+
+ skills = _discover_file_skills(str(tmp_path))
+ script = skills["my-skill"].scripts[0]
+ assert script.path is not None
+ assert not os.path.isabs(script.path)
+ assert script.path == "scripts/generate.py"
+
+ def test_discovers_nested_scripts(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ scripts_dir = skill_dir / "scripts"
+ scripts_dir.mkdir(parents=True)
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (scripts_dir / "generate.py").write_text("print('gen')", encoding="utf-8")
+
+ skills = _discover_file_skills(str(tmp_path))
+ assert len(skills["my-skill"].scripts) == 1
+ assert skills["my-skill"].scripts[0].name == "scripts/generate.py"
+
+ def test_no_scripts_when_no_py_files(self, tmp_path: Path) -> None:
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "readme.md").write_text("# Docs", encoding="utf-8")
+
+ skills = _discover_file_skills(str(tmp_path))
+ assert len(skills["my-skill"].scripts) == 0
+
+
+class TestCustomScriptExtensions:
+ """Tests for the script_extensions parameter (parity with resource_extensions)."""
+
+ def test_custom_script_extensions_via_discover_file_skills(self, tmp_path: Path) -> None:
+ """_discover_file_skills forwards script_extensions to _discover_script_files."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8")
+ (skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8")
+
+ # Default: only .py discovered
+ skills_default = _discover_file_skills(str(tmp_path))
+ script_names_default = [s.name for s in skills_default["my-skill"].scripts]
+ assert "analyze.py" in script_names_default
+ assert "run.sh" not in script_names_default
+
+ # Custom: only .sh discovered
+ skills_custom = _discover_file_skills(str(tmp_path), script_extensions=(".sh",))
+ script_names_custom = [s.name for s in skills_custom["my-skill"].scripts]
+ assert "run.sh" in script_names_custom
+ assert "analyze.py" not in script_names_custom
+
+ def test_custom_script_extensions_via_provider(self, tmp_path: Path) -> None:
+ """SkillsProvider accepts custom script_extensions."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8")
+ (skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8")
+
+ # Only discover .sh scripts
+ provider = SkillsProvider(
+ str(tmp_path),
+ script_extensions=(".sh",),
+ script_runner=_noop_script_runner,
+ )
+ skill = provider._skills["my-skill"]
+ script_names = [s.name for s in skill.scripts]
+ assert "run.sh" in script_names
+ assert "analyze.py" not in script_names
+
+ def test_multiple_script_extensions(self, tmp_path: Path) -> None:
+ """Multiple script extensions can be specified."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: test\n---\nBody",
+ encoding="utf-8",
+ )
+ (skill_dir / "analyze.py").write_text("print('hi')", encoding="utf-8")
+ (skill_dir / "run.sh").write_text("#!/bin/bash", encoding="utf-8")
+ (skill_dir / "notes.txt").write_text("notes", encoding="utf-8")
+
+ provider = SkillsProvider(
+ str(tmp_path),
+ script_extensions=(".py", ".sh"),
+ script_runner=_noop_script_runner,
+ )
+ skill = provider._skills["my-skill"]
+ script_names = [s.name for s in skill.scripts]
+ assert "analyze.py" in script_names
+ assert "run.sh" in script_names
+ assert "notes.txt" not in script_names
+
+ def test_default_script_extensions_unchanged(self) -> None:
+ """DEFAULT_SCRIPT_EXTENSIONS contains only .py."""
+ assert DEFAULT_SCRIPT_EXTENSIONS == (".py",)
+
+
+# ---------------------------------------------------------------------------
+# _create_instructions with scripts tests
+# ---------------------------------------------------------------------------
+
+
+class TestCreateInstructionsWithScripts:
+ """Tests for script metadata in skill advertisement."""
+
+ def test_excludes_script_count(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="s1", function=lambda: None))
+
+ result = _create_instructions(None, {"my-skill": skill})
+ assert result is not None
+ assert "" not in result
+
+ def test_no_scripts_element_when_empty(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+
+ result = _create_instructions(None, {"my-skill": skill})
+ assert result is not None
+ assert "" not in result
+
+
+# ---------------------------------------------------------------------------
+# _load_skill with scripts tests
+# ---------------------------------------------------------------------------
+
+
+class TestLoadSkillWithScripts:
+ """Tests for script metadata in load_skill output."""
+
+ def test_code_skill_includes_scripts_element(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="analyze", description="Run analysis", function=lambda: None))
+
+ provider = SkillsProvider(skills=[skill])
+ result = provider._load_skill("my-skill")
+
+ assert "" in result
+ assert 'name="analyze"' in result
+ assert 'description="Run analysis"' in result
+
+ def test_code_skill_no_scripts_element(self) -> None:
+ skill = Skill(name="my-skill", description="test", content="body")
+ provider = SkillsProvider(skills=[skill])
+ result = provider._load_skill("my-skill")
+ assert "" not in result
+
+ def test_code_skill_scripts_element_contains_parameters(self) -> None:
+ """Scripts XML includes parameters schema when the function has typed parameters."""
+ from agent_framework import SkillScript
+
+ def analyze(query: str, limit: int = 10) -> str:
+ return "result"
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="analyze", description="Run analysis", function=analyze))
+
+ provider = SkillsProvider(skills=[skill])
+ result = provider._load_skill("my-skill")
+
+ assert "" in result
+ assert 'name="analyze"' in result
+ assert "" in result
+ assert '"query"' in result
+
+
+class TestReadSkillResourceWithScripts:
+ """Tests for _read_skill_resource falling back to scripts."""
+
+ async def test_reads_script_with_static_content(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="generate.py", function=lambda: "print('hello')"))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "generate.py")
+ # Scripts are not returned via _read_skill_resource
+ assert "not found" in result
+
+ async def test_script_not_accessible_via_read_resource(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="run.py", function=lambda: "script output"))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "run.py")
+ # Scripts are separate from resources
+ assert "not found" in result
+
+ async def test_async_script_not_accessible_via_read_resource(self) -> None:
+ from agent_framework import SkillScript
+
+ async def async_script() -> str:
+ return "async output"
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="run.py", function=async_script))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "run.py")
+ assert "not found" in result
+
+ async def test_script_case_insensitive_not_in_resources(self) -> None:
+ from agent_framework import SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="Generate.py", function=lambda: "code"))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "generate.py")
+ assert "not found" in result
+
+ async def test_resource_takes_priority_over_script(self) -> None:
+ from agent_framework import SkillResource, SkillScript
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.resources.append(SkillResource(name="data.py", content="resource content"))
+ skill.scripts.append(SkillScript(name="data.py", function=lambda: "script content"))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "data.py")
+ assert result == "resource content"
+
+ async def test_script_function_error_not_exposed_via_resources(self) -> None:
+ from agent_framework import SkillScript
+
+ def failing_script() -> str:
+ raise RuntimeError("boom")
+
+ skill = Skill(name="my-skill", description="test", content="body")
+ skill.scripts.append(SkillScript(name="bad.py", function=failing_script))
+
+ provider = SkillsProvider(skills=[skill])
+ result = await provider._read_skill_resource("my-skill", "bad.py")
+ assert "not found" in result
+
+
+# ---------------------------------------------------------------------------
+# Tests: _generate_function_schema
+# ---------------------------------------------------------------------------
+
+
+class TestGenerateFunctionSchema:
+ """Tests for SkillScript.parameters_schema lazy generation."""
+
+ def test_simple_function(self) -> None:
+ from agent_framework import SkillScript
+
+ def analyze(query: str, limit: int) -> str:
+ return ""
+
+ script = SkillScript(name="analyze", function=analyze)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert schema["type"] == "object"
+ assert "query" in schema["properties"]
+ assert "limit" in schema["properties"]
+ assert "query" in schema["required"]
+ assert "limit" in schema["required"]
+
+ def test_optional_parameter(self) -> None:
+ from agent_framework import SkillScript
+
+ def fetch(url: str, timeout: int = 30) -> str:
+ return ""
+
+ script = SkillScript(name="fetch", function=fetch)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "url" in schema["properties"]
+ assert "timeout" in schema["properties"]
+ assert "url" in schema["required"]
+ # timeout has a default, so it should NOT be in required
+ assert "timeout" not in schema.get("required", [])
+
+ def test_no_parameters_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ def noop() -> None:
+ pass
+
+ script = SkillScript(name="noop", function=noop)
+ assert script.parameters_schema is None
+
+ def test_skips_self_and_cls(self) -> None:
+ from agent_framework import SkillScript
+
+ def method(self, query: str) -> str: # noqa: ANN001
+ return ""
+
+ script = SkillScript(name="method", function=method)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "self" not in schema["properties"]
+ assert "query" in schema["properties"]
+
+ def test_skips_var_keyword(self) -> None:
+ from agent_framework import SkillScript
+
+ def func(name: str, **kwargs: Any) -> str:
+ return ""
+
+ script = SkillScript(name="func", function=func)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "kwargs" not in schema["properties"]
+ assert "name" in schema["properties"]
+
+ def test_async_function(self) -> None:
+ from agent_framework import SkillScript
+
+ async def fetch_data(url: str) -> str:
+ return ""
+
+ script = SkillScript(name="fetch_data", function=fetch_data)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "url" in schema["properties"]
+
+ def test_bool_and_float_types(self) -> None:
+ from agent_framework import SkillScript
+
+ def process(verbose: bool, threshold: float) -> None:
+ pass
+
+ script = SkillScript(name="process", function=process)
+ schema = script.parameters_schema
+ assert schema is not None
+ assert "verbose" in schema["properties"]
+ assert "threshold" in schema["properties"]
+
+ def test_lazy_generation_is_cached(self) -> None:
+ from agent_framework import SkillScript
+
+ def analyze(query: str) -> str:
+ return ""
+
+ script = SkillScript(name="analyze", function=analyze)
+ first = script.parameters_schema
+ second = script.parameters_schema
+ assert first is second
+
+
+# ---------------------------------------------------------------------------
+# Tests: _create_script_element
+# ---------------------------------------------------------------------------
+
+
+class TestCreateScriptElement:
+ """Tests for _create_script_element."""
+
+ def test_name_only(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name="run.py", path="scripts/run.py")
+ elem = _create_script_element(s)
+ assert elem == ' '
+
+ def test_with_description(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name="run.py", description="Execute script.", path="scripts/run.py")
+ elem = _create_script_element(s)
+ assert elem == ' '
+
+ def test_xml_escapes_name(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name='script"special', path="scripts/s.py")
+ elem = _create_script_element(s)
+ assert """ in elem
+
+ def test_xml_escapes_description(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name="run.py", description='Uses & "quotes"', path="scripts/run.py")
+ elem = _create_script_element(s)
+ assert "<tags>" in elem
+ assert "&" in elem
+ assert """ in elem
+
+ def test_includes_parameters_for_code_script(self) -> None:
+ from agent_framework import SkillScript
+
+ def analyze(query: str, limit: int = 10) -> str:
+ return ""
+
+ s = SkillScript(name="analyze", description="Run analysis", function=analyze)
+ elem = _create_script_element(s)
+ assert "" in elem
+ assert "" in elem
+ assert "query" in elem
+ assert """ not in elem
+
+ def test_no_parameters_for_file_script(self) -> None:
+ from agent_framework import SkillScript
+
+ s = SkillScript(name="run.py", path="scripts/run.py")
+ elem = _create_script_element(s)
+ assert "" not in elem
+
+
+# ---------------------------------------------------------------------------
+# Tests: SkillScript.parameters_schema
+# ---------------------------------------------------------------------------
+
+
+class TestSkillScriptParametersSchema:
+ """Tests for parameters_schema auto-generation on SkillScript."""
+
+ def test_auto_generated_from_function(self) -> None:
+ from agent_framework import SkillScript
+
+ def analyze(query: str) -> str:
+ return ""
+
+ script = SkillScript(name="analyze", function=analyze)
+ assert script.parameters_schema is not None
+ assert "query" in script.parameters_schema["properties"]
+
+ def test_none_for_file_based_script(self) -> None:
+ from agent_framework import SkillScript
+
+ script = SkillScript(name="run.py", path="scripts/run.py")
+ assert script.parameters_schema is None
+
+ def test_no_params_function_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ def noop() -> None:
+ pass
+
+ script = SkillScript(name="noop", function=noop)
+ assert script.parameters_schema is None
+
+ def test_kwargs_only_function_returns_none(self) -> None:
+ from agent_framework import SkillScript
+
+ def func(**kwargs: Any) -> str:
+ return ""
+
+ script = SkillScript(name="func", function=func)
+ assert script.parameters_schema is None
+
+ def test_no_params_caching_does_not_reinspect(self) -> None:
+ """parameters_schema caches the None result and does not re-inspect."""
+ from unittest.mock import patch
+
+ from agent_framework import SkillScript
+
+ def noop() -> None:
+ pass
+
+ script = SkillScript(name="noop", function=noop)
+ first = script.parameters_schema
+ assert first is None
+ # Second access should not create a new FunctionTool
+ with patch("agent_framework._skills.FunctionTool", side_effect=RuntimeError("should not be called")):
+ second = script.parameters_schema
+ assert second is None
+
+
+# ---------------------------------------------------------------------------
+# Tests: _load_skills merging behavior
+# ---------------------------------------------------------------------------
+
+
+class TestLoadSkillsMerging:
+ """Tests for _load_skills merging file-based and code-defined skills."""
+
+ def test_code_skill_with_invalid_name_is_skipped(self) -> None:
+ """Code skills with invalid metadata (e.g. uppercase name) are skipped without raising."""
+ invalid_skill = Skill(name="my-skill", description="valid", content="body")
+ # Bypass Skill.__init__ validation by setting the name after construction
+ invalid_skill.name = "INVALID_NAME"
+
+ valid_skill = Skill(name="good-skill", description="valid", content="body")
+
+ result = _load_skills(
+ skill_paths=None,
+ skills=[invalid_skill, valid_skill],
+ resource_extensions=DEFAULT_RESOURCE_EXTENSIONS,
+ script_extensions=DEFAULT_SCRIPT_EXTENSIONS,
+ )
+ assert "good-skill" in result
+ assert "INVALID_NAME" not in result
+
+ def test_file_skill_takes_precedence_over_code_skill(self, tmp_path: Path) -> None:
+ """When file-based and code-defined skills share a name, file-based wins."""
+ skill_dir = tmp_path / "my-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: my-skill\ndescription: File skill.\n---\nFile body.",
+ encoding="utf-8",
+ )
+
+ code_skill = Skill(name="my-skill", description="Code skill.", content="Code body.")
+
+ result = _load_skills(
+ skill_paths=str(tmp_path),
+ skills=[code_skill],
+ resource_extensions=DEFAULT_RESOURCE_EXTENSIONS,
+ script_extensions=DEFAULT_SCRIPT_EXTENSIONS,
+ )
+ assert "my-skill" in result
+ assert result["my-skill"].path is not None # file-based skill has path set
diff --git a/python/samples/02-agents/skills/README.md b/python/samples/02-agents/skills/README.md
new file mode 100644
index 0000000000..29f6a85e31
--- /dev/null
+++ b/python/samples/02-agents/skills/README.md
@@ -0,0 +1,55 @@
+# Agent Skills Samples
+
+These samples demonstrate how to use **Agent Skills** — modular packages of instructions, resources, and scripts that extend an agent's capabilities. Skills follow the [Agent Skills specification](https://agentskills.io/) and use progressive disclosure to optimize token usage.
+
+## Learning Path
+
+Start with file-based or code-defined skills, then explore combining them and adding approval workflows.
+
+| Sample | Description |
+|--------|-------------|
+| [**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. |
+| [**script_approval**](script_approval/) | Require human-in-the-loop approval before executing skill scripts |
+
+## Key Concepts
+
+### Progressive Disclosure
+
+Skills use a three-step interaction model to minimize token usage:
+
+1. **Advertise** — Skill names and descriptions (~100 tokens each) are injected into the system prompt
+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
+
+| 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) |
+
+Both types can be combined in a single `SkillsProvider` — see the [mixed_skills](mixed_skills/) sample.
+
+### Script Execution
+
+Skills can include executable scripts. How a script runs depends on how it was defined:
+
+| | Code-Defined Scripts | File-Based Scripts |
+|---|---|---|
+| **Defined via** | `@skill.script` decorator | `.py` files in `scripts/` directory |
+| **Execution** | In-process (direct function call) | Delegated to a `script_runner` |
+| **`script_runner` needed?** | No — runs in-process automatically | **Yes** — required |
+
+The `script_runner` parameter on `SkillsProvider` is only applicable to **file-based** scripts. Code-defined scripts are always executed in-process regardless of this setting. See [file_based_skill](file_based_skill/) for an example using a `SkillScriptRunner` callable with a subprocess runner, and [code_defined_skill](code_defined_skill/) for in-process scripts that need no runner.
+
+## Prerequisites
+
+All samples require:
+- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`)
+- Azure CLI authentication (`az login`)
+- Environment variables set in a `.env` file (see `python/.env.example`)
diff --git a/python/samples/02-agents/skills/basic_skill/README.md b/python/samples/02-agents/skills/basic_skill/README.md
deleted file mode 100644
index 1e8e4870e9..0000000000
--- a/python/samples/02-agents/skills/basic_skill/README.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# Agent Skills Sample
-
-This sample demonstrates how to use **Agent Skills** with a `SkillsProvider` in the Microsoft Agent Framework.
-
-## What are Agent Skills?
-
-Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern:
-
-1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
-2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
-3. **Resources**: References and other files loaded via `read_skill_resource` tool
-
-## Skills Included
-
-### expense-report
-Policy-based expense filing with spending limits, receipt requirements, and approval workflows.
-- `references/POLICY_FAQ.md` — Detailed expense policy Q&A
-- `assets/expense-report-template.md` — Submission template
-
-## Project Structure
-
-```
-basic_skill/
-├── basic_skill.py
-├── README.md
-└── skills/
- └── expense-report/
- ├── SKILL.md
- ├── references/
- │ └── POLICY_FAQ.md
- └── assets/
- └── expense-report-template.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`):
-
-- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
-- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: 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/basic_skill/basic_skill.py
-```
-
-### Examples
-
-The sample runs two examples:
-
-1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource
-2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset
-
-## Learn More
-
-- [Agent Skills Specification](https://agentskills.io/)
-- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/python/samples/02-agents/skills/basic_skill/basic_skill.py b/python/samples/02-agents/skills/basic_skill/basic_skill.py
deleted file mode 100644
index c2f18f73f8..0000000000
--- a/python/samples/02-agents/skills/basic_skill/basic_skill.py
+++ /dev/null
@@ -1,88 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
-
-import asyncio
-import os
-from pathlib import Path
-
-from agent_framework import Agent, SkillsProvider
-from agent_framework.azure import AzureOpenAIResponsesClient
-from azure.identity import AzureCliCredential
-from dotenv import load_dotenv
-
-"""
-Agent Skills Sample
-
-This sample demonstrates how to use file-based Agent Skills with a SkillsProvider.
-Agent Skills are modular packages of instructions and resources that extend an agent's
-capabilities. They follow the progressive disclosure pattern:
-
-1. Advertise — skill names and descriptions are injected into the system prompt
-2. Load — full instructions are loaded on-demand via the load_skill tool
-3. Read resources — supplementary files are read via the read_skill_resource tool
-
-This sample includes the expense-report skill:
- - Policy-based expense filing with references and assets
-"""
-
-# Load environment variables from .env file
-load_dotenv()
-
-
-async def main() -> None:
- """Run the Agent Skills demo."""
- # --- Configuration ---
- endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
- deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
-
- # --- 1. Create the chat client ---
- client = AzureOpenAIResponsesClient(
- project_endpoint=endpoint,
- deployment_name=deployment,
- credential=AzureCliCredential(),
- )
-
- # --- 2. Create the skills provider ---
- # Discovers skills from the 'skills' directory and makes them available to the agent
- skills_dir = Path(__file__).parent / "skills"
- skills_provider = SkillsProvider(skill_paths=str(skills_dir))
-
- # --- 3. Create the agent with skills ---
- async with Agent(
- client=client,
- instructions="You are a helpful assistant.",
- context_providers=[skills_provider],
- ) as agent:
- # --- Example 1: Expense policy question (loads FAQ resource) ---
- print("Example 1: Checking expense policy FAQ")
- print("---------------------------------------")
- response1 = await agent.run(
- "Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."
- )
- print(f"Agent: {response1}\n")
-
- # --- Example 2: Filing an expense report (uses template asset) ---
- print("Example 2: Filing an expense report")
- print("---------------------------------------")
- session = agent.create_session()
- response2 = await agent.run(
- "I had 3 client dinners and a $1,200 flight last week. "
- "Return a draft expense report and ask about any missing details.",
- session=session,
- )
- print(f"Agent: {response2}\n")
-
-
-if __name__ == "__main__":
- asyncio.run(main())
-
-"""
-Sample output:
-Example 1: Checking expense policy FAQ
----------------------------------------
-Agent: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping.
-Since you left a 25% tip, the portion above 20% would require written justification...
-
-Example 2: Filing an expense report
----------------------------------------
-Agent: Here's a draft expense report based on what you've told me. I'll need a few more details...
-"""
diff --git a/python/samples/02-agents/skills/basic_skill/skills/expense-report/SKILL.md b/python/samples/02-agents/skills/basic_skill/skills/expense-report/SKILL.md
deleted file mode 100644
index fc6c83cf30..0000000000
--- a/python/samples/02-agents/skills/basic_skill/skills/expense-report/SKILL.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-name: expense-report
-description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories.
-metadata:
- author: contoso-finance
- version: "2.1"
----
-
-# Expense Report
-
-## Categories and Limits
-
-| Category | Limit | Receipt | Approval |
-|---|---|---|---|
-| Meals — solo | $50/day | >$25 | No |
-| Meals — team/client | $75/person | Always | Manager if >$200 total |
-| Lodging | $250/night | Always | Manager if >3 nights |
-| Ground transport | $100/day | >$15 | No |
-| Airfare | Economy | Always | Manager; VP if >$1,500 |
-| Conference/training | $2,000/event | Always | Manager + L&D |
-| Office supplies | $100 | Yes | No |
-| Software/subscriptions | $50/month | Yes | Manager if >$200/year |
-
-## Filing Process
-
-1. Collect receipts — must show vendor, date, amount, payment method.
-2. Categorize per table above.
-3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md).
-4. For client/team meals: list attendee names and business purpose.
-5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000.
-6. Reimbursement: 10 business days via direct deposit.
-
-## Policy Rules
-
-- Submit within 30 days of transaction.
-- Alcohol is never reimbursable.
-- Foreign currency: convert to USD at transaction-date rate; note original currency and amount.
-- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes.
-- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter.
-- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state.
diff --git a/python/samples/02-agents/skills/basic_skill/skills/expense-report/assets/expense-report-template.md b/python/samples/02-agents/skills/basic_skill/skills/expense-report/assets/expense-report-template.md
deleted file mode 100644
index 3f7c7dc36c..0000000000
--- a/python/samples/02-agents/skills/basic_skill/skills/expense-report/assets/expense-report-template.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Expense Report Template
-
-| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached |
-|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------|
-| | | | | | | | | | Yes or No |
diff --git a/python/samples/02-agents/skills/basic_skill/skills/expense-report/references/POLICY_FAQ.md b/python/samples/02-agents/skills/basic_skill/skills/expense-report/references/POLICY_FAQ.md
deleted file mode 100644
index 8e971192f8..0000000000
--- a/python/samples/02-agents/skills/basic_skill/skills/expense-report/references/POLICY_FAQ.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Expense Policy — Frequently Asked Questions
-
-## Meals
-
-**Q: Can I expense coffee or snacks during the workday?**
-A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal.
-
-**Q: What if a team dinner exceeds the per-person limit?**
-A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP.
-
-**Q: Do I need to list every attendee?**
-A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list.
-
-## Travel
-
-**Q: Can I book a premium economy or business class flight?**
-A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation.
-
-**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?**
-A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people.
-
-**Q: Are tips reimbursable?**
-A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification.
-
-## Lodging
-
-**Q: What if the $250/night limit isn't enough for the city I'm visiting?**
-A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking.
-
-**Q: Can I stay with friends/family instead and get a per-diem?**
-A: No. Contoso reimburses actual lodging costs only, not per-diems.
-
-## Subscriptions and Software
-
-**Q: Can I expense a personal productivity tool?**
-A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing.
-
-**Q: What about annual subscriptions?**
-A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report.
-
-## Receipts and Documentation
-
-**Q: My receipt is faded/damaged. What do I do?**
-A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter.
-
-**Q: Do I need a receipt for parking meters or tolls?**
-A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required.
-
-## Approval and Reimbursement
-
-**Q: My manager is on leave. Who approves my report?**
-A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system.
-
-**Q: Can I submit expenses from a previous quarter?**
-A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval.
diff --git a/python/samples/02-agents/skills/code_defined_skill/README.md b/python/samples/02-agents/skills/code_defined_skill/README.md
new file mode 100644
index 0000000000..ae70268ca4
--- /dev/null
+++ b/python/samples/02-agents/skills/code_defined_skill/README.md
@@ -0,0 +1,49 @@
+# Code-Defined Agent Skills
+
+This sample demonstrates how to create **Agent Skills** in Python code, without needing `SKILL.md` files on disk. A unit-converter skill shows three approaches:
+
+## What's Demonstrated
+
+1. **Static Resources** — Pass inline content via the `resources` parameter when constructing a `Skill`
+2. **Dynamic Resources** — Attach callable functions via the `@skill.resource` decorator that return content computed at runtime
+3. **Dynamic Scripts** — Attach callable scripts via the `@skill.script` decorator (unit conversion via a single factor parameter)
+
+All three can be combined with file-based skills in a single `SkillsProvider`.
+
+## Project Structure
+
+```
+code_defined_skill/
+├── code_defined_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`):
+
+- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
+- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: 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/code_defined_skill/code_defined_skill.py
+```
+
+## Learn More
+
+- [Agent Skills Specification](https://agentskills.io/)
+- [File-Based Skills Sample](../file_based_skill/)
+- [Mixed Skills Sample](../mixed_skills/)
+- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py
new file mode 100644
index 0000000000..e9b4757bb6
--- /dev/null
+++ b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py
@@ -0,0 +1,173 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import json
+import os
+from textwrap import dedent
+from typing import Any
+
+from agent_framework import Agent, Skill, SkillResource, SkillsProvider
+from agent_framework.azure import AzureOpenAIResponsesClient
+from azure.identity import AzureCliCredential
+from dotenv import load_dotenv
+
+"""
+Code-Defined Agent Skills — Define skills in Python code
+
+This sample demonstrates how to create Agent Skills in code,
+without needing SKILL.md files on disk. Three approaches are shown
+using a unit-converter skill:
+
+1. Static Resources
+ Pass inline content directly via the ``resources`` parameter when
+ constructing the Skill.
+
+2. Dynamic Resources
+ Attach a callable resource via the @skill.resource decorator. The
+ function is invoked on demand, so it can return data computed at
+ runtime.
+
+3. Dynamic Scripts
+ Attach a callable script via the @skill.script decorator. Scripts are
+ executable functions the agent can invoke directly in-process.
+
+Code-defined skills can be combined with file-based skills in a single
+SkillsProvider — see the mixed_skills sample.
+"""
+
+# Load environment variables from .env file
+load_dotenv()
+
+# ---------------------------------------------------------------------------
+# 1. Static Resources — inline content passed at construction time
+# ---------------------------------------------------------------------------
+unit_converter_skill = Skill(
+ name="unit-converter",
+ description="Convert between common units using a conversion factor",
+ content=dedent("""\
+ Use this skill when the user asks to convert between units.
+
+ 1. Review the conversion-tables resource to find the factor for the
+ requested conversion.
+ 2. Check the conversion-policy resource for rounding and formatting rules.
+ 3. Use the convert script, passing the value and factor from the table.
+ """),
+ resources=[
+ SkillResource(
+ name="conversion-tables",
+ content=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. Dynamic Resources — callable function via @skill.resource
+# ---------------------------------------------------------------------------
+@unit_converter_skill.resource(name="conversion-policy", description="Current conversion formatting and rounding policy")
+def conversion_policy(**kwargs: Any) -> Any:
+ """Return the current conversion policy.
+
+ Dynamic resources are evaluated at runtime, so they can include
+ live data such as dates, configuration values, or database lookups.
+
+ When the resource function accepts ``**kwargs``, runtime keyword
+ arguments passed to ``agent.run()`` are forwarded automatically.
+
+ Args:
+ **kwargs: Runtime keyword arguments from ``agent.run()``.
+ For example, ``agent.run(..., precision=2)``
+ makes ``kwargs["precision"]`` available here.
+ """
+ precision = kwargs.get("precision", 4)
+ return dedent(f"""\
+ # Conversion Policy
+
+ **Decimal places:** {precision}
+ **Format:** Always show both the original and converted values with units
+ """)
+
+
+# ---------------------------------------------------------------------------
+# 3. Dynamic Scripts — in-process callable function
+# ---------------------------------------------------------------------------
+@unit_converter_skill.script(name="convert", description="Convert a value: result = value × factor")
+def convert_units(value: float, factor: float, **kwargs: Any) -> str:
+ """Convert a value using a multiplication factor: result = value × factor.
+
+ The caller looks up the correct factor from the conversion-tables
+ resource and passes it here.
+
+ Args:
+ value: The numeric value to convert.
+ factor: Conversion factor from the conversion table.
+ **kwargs: Runtime keyword arguments from ``agent.run()``.
+ The ``precision`` kwarg controls how many decimal places
+ the result is rounded to (default 4).
+
+ Returns:
+ JSON string with the inputs and converted result.
+ """
+ precision = kwargs.get("precision", 4)
+ result = round(value * factor, precision)
+ return json.dumps({"value": value, "factor": factor, "result": result})
+
+
+async def main() -> None:
+ """Run the code-defined skills demo."""
+ endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
+ deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
+
+ client = AzureOpenAIResponsesClient(
+ project_endpoint=endpoint,
+ deployment_name=deployment,
+ credential=AzureCliCredential(),
+ )
+
+ # Create the skills provider with the code-defined skill
+ skills_provider = SkillsProvider(
+ skills=[unit_converter_skill],
+ )
+
+ async with Agent(
+ client=client,
+ instructions="You are a helpful assistant that can convert units.",
+ context_providers=[skills_provider],
+ ) as agent:
+ print("Converting units")
+ print("-" * 60)
+ response = await agent.run(
+ "How many kilometers is a marathon (26.2 miles)? "
+ "And how many pounds is 75 kilograms?",
+ precision=2,
+ )
+ print(f"Agent: {response}\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+Converting units
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **75 kg → 165.35 lbs**
+
+I used the conversion factors from the reference table:
+miles × 1.60934 and kilograms × 2.20462.
+"""
diff --git a/python/samples/02-agents/skills/code_skill/README.md b/python/samples/02-agents/skills/code_skill/README.md
deleted file mode 100644
index 4900d00eb5..0000000000
--- a/python/samples/02-agents/skills/code_skill/README.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# Code-Defined Agent Skills Sample
-
-This sample demonstrates how to create **Agent Skills** in Python code, without needing `SKILL.md` files on disk.
-
-## What are Code-Defined Skills?
-
-While file-based skills use `SKILL.md` files discovered on disk, code-defined skills let you define skills entirely in Python using `Skill` and `SkillResource` classes. Three patterns are shown:
-
-1. **Basic Code Skill** — Create a `Skill` directly with static resources (inline content)
-2. **Dynamic Resources** — Attach callable resources via the `@skill.resource` decorator that generate content at invocation time
-3. **Dynamic Resources with kwargs** — Attach a callable resource that accepts `**kwargs` to receive runtime arguments passed via `agent.run()`, useful for injecting request-scoped context (user tokens, session data)
-
-All patterns can be combined with file-based skills in a single `SkillsProvider`.
-
-## Project Structure
-
-```
-code_skill/
-├── code_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`):
-
-- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
-- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: 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/code_skill/code_skill.py
-```
-
-### Examples
-
-The sample runs two examples:
-
-1. **Code style question** — Uses Pattern 1 (static resources): the agent loads the `code-style` skill and reads the `style-guide` resource to answer naming convention questions
-2. **Project info question** — Uses Patterns 2 & 3 (dynamic resources with kwargs): the agent reads the dynamically generated `team-roster` resource and the `environment` resource which receives `app_version` via runtime kwargs
-
-## Learn More
-
-- [Agent Skills Specification](https://agentskills.io/)
-- [File-based Skills Sample](../basic_skill/)
-- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/python/samples/02-agents/skills/code_skill/code_skill.py b/python/samples/02-agents/skills/code_skill/code_skill.py
deleted file mode 100644
index e111567244..0000000000
--- a/python/samples/02-agents/skills/code_skill/code_skill.py
+++ /dev/null
@@ -1,161 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
-
-import asyncio
-import os
-import sys
-from textwrap import dedent
-from typing import Any
-
-from agent_framework import Agent, Skill, SkillResource, SkillsProvider
-from agent_framework.azure import AzureOpenAIResponsesClient
-from azure.identity import AzureCliCredential
-from dotenv import load_dotenv
-
-"""
-Code-Defined Agent Skills — Define skills in Python code
-
-This sample demonstrates how to create Agent Skills in code,
-without needing SKILL.md files on disk. Three patterns are shown:
-
-Pattern 1: Basic Code Skill
- Create a Skill instance directly with static resources (inline content).
-
-Pattern 2: Dynamic Resources
- Create a Skill and attach callable resources via the @skill.resource
- decorator. Resources can be sync or async functions that generate content at
- invocation time.
-
-Pattern 3: Dynamic Resources with kwargs
- Attach a callable resource that accepts **kwargs to receive runtime
- arguments passed via agent.run(). This is useful for injecting
- request-scoped context (user tokens, session data) into skill resources.
-
-Both patterns can be combined with file-based skills in a single SkillsProvider.
-"""
-
-# Load environment variables from .env file
-load_dotenv()
-
-# Pattern 1: Basic Code Skill — direct construction with static resources
-code_style_skill = Skill(
- name="code-style",
- description="Coding style guidelines and conventions for the team",
- content=dedent("""\
- Use this skill when answering questions about coding style, conventions,
- or best practices for the team.
- """),
- resources=[
- SkillResource(
- name="style-guide",
- content=dedent("""\
- # Team Coding Style Guide
-
- ## General Rules
- - Use 4-space indentation (no tabs)
- - Maximum line length: 120 characters
- - Use type annotations on all public functions
- - Use Google-style docstrings
-
- ## Naming Conventions
- - Classes: PascalCase (e.g., UserAccount)
- - Functions/methods: snake_case (e.g., get_user_name)
- - Constants: UPPER_SNAKE_CASE (e.g., MAX_RETRIES)
- - Private members: prefix with underscore (e.g., _internal_state)
- """),
- ),
- ],
-)
-
-# Pattern 2: Dynamic Resources — @skill.resource decorator
-project_info_skill = Skill(
- name="project-info",
- description="Project status and configuration information",
- content=dedent("""\
- Use this skill for questions about the current project status,
- environment configuration, or team structure.
- """),
-)
-
-
-@project_info_skill.resource
-def environment(**kwargs: Any) -> str:
- """Get current environment configuration."""
- # Access runtime kwargs passed via agent.run(app_version="...")
- app_version = kwargs.get("app_version", "unknown")
- env = os.environ.get("APP_ENV", "development")
- region = os.environ.get("APP_REGION", "us-east-1")
- return f"""\
- # Environment Configuration
- - App Version: {app_version}
- - Environment: {env}
- - Region: {region}
- - Python: {sys.version}
- """
-
-
-@project_info_skill.resource(name="team-roster", description="Current team members and roles")
-def get_team_roster() -> str:
- """Return the team roster."""
- return """\
- # Team Roster
- | Name | Role |
- |--------------|-------------------|
- | Alice Chen | Tech Lead |
- | Bob Smith | Backend Engineer |
- | Carol Davis | Frontend Engineer |
- """
-
-
-async def main() -> None:
- """Run the code-defined skills demo."""
- endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
- deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
-
- client = AzureOpenAIResponsesClient(
- project_endpoint=endpoint,
- deployment_name=deployment,
- credential=AzureCliCredential(),
- )
-
- # Create the skills provider with both code-defined skills
- skills_provider = SkillsProvider(
- skills=[code_style_skill, project_info_skill],
- )
-
- async with Agent(
- client=client,
- instructions="You are a helpful assistant for our development team.",
- context_providers=[skills_provider],
- ) as agent:
- # Example 1: Code style question (Pattern 1 — static resources)
- print("Example 1: Code style question")
- print("-------------------------------")
- response = await agent.run("What naming convention should I use for class attributes?")
- print(f"Agent: {response}\n")
-
- # Example 2: Project info question (Pattern 2 & 3 — dynamic resources with kwargs)
- print("Example 2: Project info question")
- print("---------------------------------")
- # Pass app_version as a runtime kwarg; it flows to the environment() resource via **kwargs
- response = await agent.run("What environment are we running in and who is on the team?", app_version="2.4.1")
- print(f"Agent: {response}\n")
-
- """
- Expected output:
-
- Example 1: Code style question
- -------------------------------
- Agent: Based on our team's coding style guide, class attributes should follow
- snake_case naming. Private attributes use an underscore prefix (_internal_state).
- Constants use UPPER_SNAKE_CASE (MAX_RETRIES).
-
- Example 2: Project info question
- ---------------------------------
- Agent: We're running app version 2.4.1 in the development environment
- in us-east-1. The team consists of Alice Chen (Tech Lead), Bob Smith
- (Backend Engineer), and Carol Davis (Frontend Engineer).
- """
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/python/samples/02-agents/skills/file_based_skill/README.md b/python/samples/02-agents/skills/file_based_skill/README.md
new file mode 100644
index 0000000000..ebc686941f
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/README.md
@@ -0,0 +1,69 @@
+# File-Based Agent Skills
+
+This sample demonstrates how to use **file-based Agent Skills** with a `SkillsProvider` in the Microsoft Agent Framework. File-based skills are discovered from `SKILL.md` files on disk and can include reference documents and executable scripts.
+
+## What are Agent Skills?
+
+Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement progressive disclosure:
+
+1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
+2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
+3. **Resources**: References and other files loaded via `read_skill_resource` tool
+4. **Scripts**: Executable scripts run via `run_skill_script` tool
+
+## Skills Included
+
+### unit-converter
+Converts between common units (miles↔km, pounds↔kg) using a multiplication factor following [agentskills.io guidelines](https://agentskills.io/skill-creation/using-scripts).
+- `references/CONVERSION_TABLES.md` — Supported conversions and their factors
+- `scripts/convert.py` — Executable script with `--value` and `--factor` flags, JSON output, and `--help` support
+
+## Key Components
+
+- **`SkillsProvider`** — Discovers skills from `SKILL.md` files in a directory and registers tools for the agent
+- **`subprocess_script_runner`** — A `SkillScriptRunner` callback that runs scripts as local Python subprocesses, enabling the `run_skill_script` tool. Converts argument dicts to CLI flags (e.g. `{"value": 26.2, "factor": 1.60934}` → `--value 26.2 --factor 1.60934`). Shared across samples in [`../subprocess_script_runner.py`](../subprocess_script_runner.py).
+
+## Project Structure
+
+```
+file_based_skill/
+├── file_based_skill.py
+├── README.md
+└── skills/
+ └── unit-converter/
+ ├── SKILL.md
+ ├── references/
+ │ └── CONVERSION_TABLES.md
+ └── scripts/
+ └── convert.py
+```
+
+## 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`):
+
+- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
+- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: 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/file_based_skill/file_based_skill.py
+```
+
+## Learn More
+
+- [Agent Skills Specification](https://agentskills.io/)
+- [Code-Defined Skills Sample](../code_defined_skill/)
+- [Mixed Skills Sample](../mixed_skills/)
+- [Microsoft Agent Framework Documentation](../../../../../docs/)
diff --git a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py
new file mode 100644
index 0000000000..044514e7b7
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py
@@ -0,0 +1,94 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+from agent_framework import Agent, SkillsProvider
+from agent_framework.azure import AzureOpenAIResponsesClient
+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
+
+"""
+File-Based Agent Skills
+
+This sample demonstrates how to use file-based Agent Skills with a SkillsProvider.
+Agent Skills are modular packages of instructions and resources that extend an agent's
+capabilities. They follow progressive disclosure:
+
+1. Advertise — skill names and descriptions are injected into the system prompt
+2. Load — full instructions are loaded on-demand via the load_skill tool
+3. Read resources — supplementary files are read via the read_skill_resource tool
+4. Run scripts — skill scripts are run via the run_skill_script tool
+
+This sample includes the unit-converter skill which demonstrates all three
+file-based capabilities: instructions (SKILL.md), resources (CONVERSION_TABLES.md),
+and scripts (convert.py).
+"""
+
+# Load environment variables from .env file
+load_dotenv()
+
+
+async def main() -> None:
+ """Run the file-based skills demo."""
+ endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
+ deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
+
+ # Create the chat client
+ client = AzureOpenAIResponsesClient(
+ project_endpoint=endpoint,
+ deployment_name=deployment,
+ credential=AzureCliCredential(),
+ )
+
+ # Create the skills provider
+ # 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(
+ skill_paths=str(skills_dir),
+ script_runner=subprocess_script_runner,
+ )
+
+ # Create the agent with skills
+ async with Agent(
+ client=client,
+ instructions="You are a helpful assistant.",
+ context_providers=[skills_provider],
+ ) as agent:
+ # The agent will: load the unit-converter skill, read the conversion
+ # tables resource, then execute the convert.py script.
+ print("Converting units")
+ 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
+------------------------------------------------------------
+Agent: Here are your conversions:
+
+1. **26.2 miles → 42.16 km** (a marathon distance)
+2. **75 kg → 165.35 lbs**
+
+I used the conversion factors from the reference table:
+miles × 1.60934 and kilograms × 2.20462.
+"""
diff --git a/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/SKILL.md b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/SKILL.md
new file mode 100644
index 0000000000..b6e6bef1a3
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: unit-converter
+description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
+---
+
+## Usage
+
+When the user requests a unit conversion:
+1. First, review `references/CONVERSION_TABLES.md` to find the correct factor
+2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`)
+3. Present the converted value clearly with both units
diff --git a/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/references/CONVERSION_TABLES.md b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/references/CONVERSION_TABLES.md
new file mode 100644
index 0000000000..7a0160b854
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/references/CONVERSION_TABLES.md
@@ -0,0 +1,10 @@
+# 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 |
diff --git a/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py
new file mode 100644
index 0000000000..228c8809ff
--- /dev/null
+++ b/python/samples/02-agents/skills/file_based_skill/skills/unit-converter/scripts/convert.py
@@ -0,0 +1,29 @@
+# Unit conversion script
+# Converts a value using a multiplication factor: result = value × factor
+#
+# Usage:
+# python scripts/convert.py --value 26.2 --factor 1.60934
+# python scripts/convert.py --value 75 --factor 2.20462
+
+import argparse
+import json
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Convert a value using a multiplication factor.",
+ epilog="Examples:\n"
+ " python scripts/convert.py --value 26.2 --factor 1.60934\n"
+ " python scripts/convert.py --value 75 --factor 2.20462",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.")
+ parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.")
+ 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()
diff --git a/python/samples/02-agents/skills/mixed_skills/README.md b/python/samples/02-agents/skills/mixed_skills/README.md
new file mode 100644
index 0000000000..33b6760719
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/README.md
@@ -0,0 +1,100 @@
+# Mixed Skills — Code Skills 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`.
+
+## 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 |
+| **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 |
+
+## Skills in This Sample
+
+### volume-converter (code skill)
+
+Defined entirely in Python code using decorators:
+
+- **`@skill.resource`** — `conversion-table`: gallons↔liters conversion factors
+- **`@skill.script`** — `convert`: converts a value using a multiplication factor
+
+Code scripts run **in-process** — no subprocess or external runner needed.
+
+### unit-converter (file skill)
+
+Discovered from `skills/unit-converter/SKILL.md`:
+
+- **Reference**: `references/CONVERSION_TABLES.md` — supported unit conversions and their factors
+- **Script**: `scripts/convert.py` — converts a value using a multiplication factor (e.g. miles to kilometers)
+
+File scripts are executed as **local Python subprocesses** via the
+`script_runner` callback.
+
+## How It Works
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ SkillsProvider( │
+│ skill_paths="./skills", # file skills │
+│ skills=[volume_converter_skill], # code skills │
+│ script_runner=runner, │
+│ ) │
+└─────────────┬───────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ script_runner(skill, script, args) │
+│ │
+│ • Code scripts (@skill.script) → in-process call │
+│ • File scripts (scripts/*.py) → subprocess via │
+│ the callback function │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Prerequisites
+
+Set environment variables (or create a `.env` file):
+
+```
+AZURE_AI_PROJECT_ENDPOINT=https://your-project.openai.azure.com/
+AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4o-mini
+```
+
+Authenticate with Azure CLI:
+
+```bash
+az login
+```
+
+## Running the Sample
+
+```bash
+cd python
+uv run samples/02-agents/skills/mixed_skills/mixed_skills.py
+```
+
+## Directory Structure
+
+```
+mixed_skills/
+├── mixed_skills.py # Main sample — wires code + file skills together
+├── README.md
+└── skills/
+ └── unit-converter/ # File-based skill (discovered from SKILL.md)
+ ├── SKILL.md
+ ├── references/
+ │ └── CONVERSION_TABLES.md
+ └── scripts/
+ └── convert.py
+```
+
+## Learn More
+
+- [File-Based Skills Sample](../file_based_skill/)
+- [Code-Defined Skills Sample](../code_defined_skill/)
+- [Script Approval Sample](../script_approval/)
+- [Agent Skills Specification](https://agentskills.io/)
diff --git a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
new file mode 100644
index 0000000000..4e0d9173b7
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py
@@ -0,0 +1,160 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import json
+import os
+import sys
+from pathlib import Path
+from textwrap import dedent
+from typing import Any
+
+from agent_framework import (
+ Agent,
+ Skill,
+ SkillsProvider,
+)
+from agent_framework.azure import AzureOpenAIResponsesClient
+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
+
+"""
+Mixed Skills — Code skills 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.
+
+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.
+- 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.
+
+The sample registers two 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,
+ pounds↔kg) via a subprocess-executed Python script discovered from
+ ``skills/unit-converter/SKILL.md``.
+"""
+
+# Load environment variables from .env file
+load_dotenv()
+
+# ---------------------------------------------------------------------------
+# 1. Define a code skill with @skill.script and @skill.resource decorators
+# ---------------------------------------------------------------------------
+
+volume_converter_skill = Skill(
+ name="volume-converter",
+ description="Convert between gallons and liters using a conversion factor",
+ content=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.
+ 2. Use the convert script, passing the value and factor.
+ """),
+)
+
+
+@volume_converter_skill.resource(name="conversion-table", description="Volume conversion factors")
+def volume_table() -> Any:
+ """Return the volume conversion factor table."""
+ return dedent("""\
+ # Volume Conversion Table
+
+ Formula: **result = value × factor**
+
+ | From | To | Factor |
+ |---------|--------|---------|
+ | gallons | liters | 3.78541 |
+ | liters | gallons| 0.264172|
+ """)
+
+
+@volume_converter_skill.script(name="convert", description="Convert a value: result = value × factor")
+def convert_volume(value: float, factor: float) -> str:
+ """Convert a value using a multiplication factor.
+
+ Args:
+ value: The numeric value to convert.
+ factor: Conversion factor from the table.
+
+ Returns:
+ JSON string with the conversion result.
+ """
+ result = round(value * factor, 4)
+ return json.dumps({"value": value, "factor": factor, "result": result})
+
+
+# ---------------------------------------------------------------------------
+# 2. Wire everything together and run the agent
+# ---------------------------------------------------------------------------
+
+
+async def main() -> None:
+ """Run the combined skills demo."""
+ endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
+ deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
+
+ # Create the chat client
+ client = AzureOpenAIResponsesClient(
+ project_endpoint=endpoint,
+ deployment_name=deployment,
+ 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.
+ skills_dir = Path(__file__).parent / "skills"
+ skills_provider = SkillsProvider(
+ skill_paths=str(skills_dir),
+ skills=[volume_converter_skill],
+ script_runner=subprocess_script_runner,
+ )
+
+ # Run the agent
+ async with Agent(
+ client=client,
+ instructions="You are a helpful assistant that can convert units.",
+ context_providers=[skills_provider],
+ ) as agent:
+ # Ask the agent to use both skills
+ print("Converting units")
+ print("-" * 60)
+ response = await agent.run(
+ "How many kilometers is a marathon (26.2 miles)? "
+ "And how many liters is a 5-gallon bucket?"
+ )
+ print(f"Agent: {response}\n")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+Converting units
+------------------------------------------------------------
+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.
+"""
diff --git a/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/SKILL.md b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/SKILL.md
new file mode 100644
index 0000000000..b6e6bef1a3
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: unit-converter
+description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
+---
+
+## Usage
+
+When the user requests a unit conversion:
+1. First, review `references/CONVERSION_TABLES.md` to find the correct factor
+2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`)
+3. Present the converted value clearly with both units
diff --git a/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/references/CONVERSION_TABLES.md b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/references/CONVERSION_TABLES.md
new file mode 100644
index 0000000000..7a0160b854
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/references/CONVERSION_TABLES.md
@@ -0,0 +1,10 @@
+# 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 |
diff --git a/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py
new file mode 100644
index 0000000000..228c8809ff
--- /dev/null
+++ b/python/samples/02-agents/skills/mixed_skills/skills/unit-converter/scripts/convert.py
@@ -0,0 +1,29 @@
+# Unit conversion script
+# Converts a value using a multiplication factor: result = value × factor
+#
+# Usage:
+# python scripts/convert.py --value 26.2 --factor 1.60934
+# python scripts/convert.py --value 75 --factor 2.20462
+
+import argparse
+import json
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Convert a value using a multiplication factor.",
+ epilog="Examples:\n"
+ " python scripts/convert.py --value 26.2 --factor 1.60934\n"
+ " python scripts/convert.py --value 75 --factor 2.20462",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.")
+ parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.")
+ 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()
diff --git a/python/samples/02-agents/skills/script_approval/README.md b/python/samples/02-agents/skills/script_approval/README.md
new file mode 100644
index 0000000000..5392e3f2ae
--- /dev/null
+++ b/python/samples/02-agents/skills/script_approval/README.md
@@ -0,0 +1,50 @@
+# Script Approval — Human-in-the-Loop for Skill Scripts
+
+This sample demonstrates how to require **human approval** before executing skill scripts using the `require_script_approval=True` option on `SkillsProvider`.
+
+## How It Works
+
+When `require_script_approval=True` is set, the agent pauses before executing any skill script and returns approval requests instead:
+
+1. The agent tries to call `run_skill_script` — execution is paused
+2. `result.user_input_requests` contains approval request(s) with function name and arguments
+3. The application inspects each request and decides to approve or reject
+4. `request.to_function_approval_response(approved=True|False)` creates the response
+5. The response is sent back via `agent.run(approval_response, session=session)`
+6. If approved, the script executes; if rejected, the agent receives an error
+
+## Key Components
+
+- **`require_script_approval=True`** — Gates all script execution on human approval
+- **`result.user_input_requests`** — Contains pending approval requests after `agent.run()`
+- **`request.to_function_approval_response()`** — Creates an approval or rejection response
+
+## 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`):
+
+- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint
+- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: 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/script_approval/script_approval.py
+```
+
+## Learn More
+
+- [File-Based Skills Sample](../file_based_skill/)
+- [Code-Defined Skills Sample](../code_defined_skill/)
+- [Mixed Skills Sample](../mixed_skills/)
+- [Agent Skills Specification](https://agentskills.io/)
diff --git a/python/samples/02-agents/skills/script_approval/script_approval.py b/python/samples/02-agents/skills/script_approval/script_approval.py
new file mode 100644
index 0000000000..701d88de06
--- /dev/null
+++ b/python/samples/02-agents/skills/script_approval/script_approval.py
@@ -0,0 +1,124 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import os
+from textwrap import dedent
+
+from agent_framework import Agent, Skill, SkillsProvider
+from agent_framework.azure import AzureOpenAIResponsesClient
+from azure.identity import AzureCliCredential
+from dotenv import load_dotenv
+
+"""
+Skill Script Approval — Require human approval before executing skill scripts
+
+This sample demonstrates how to use ``require_script_approval=True`` on
+:class:`SkillsProvider` so that every call to ``run_skill_script`` is
+gated by a human-in-the-loop approval step.
+
+How it works:
+1. A code-defined skill with a script is registered via SkillsProvider.
+2. ``require_script_approval=True`` causes the agent to pause and return
+ approval requests in ``result.user_input_requests`` instead of executing
+ scripts immediately.
+3. The application inspects each request and calls
+ ``request.to_function_approval_response(approved=True|False)`` to approve
+ or reject.
+4. The approval response is sent back via ``agent.run(approval_response, session=session)``
+ and the agent continues — executing the script if approved, or receiving
+ an error if rejected.
+
+Prerequisites:
+- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
+- AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME (defaults to "gpt-4o-mini").
+"""
+
+# Load environment variables from .env file
+load_dotenv()
+
+# Define a code skill with a script that performs a sensitive operation
+deployment_skill = Skill(
+ name="deployment",
+ description="Tools for deploying application versions to production",
+ content=dedent("""\
+ Use this skill when the user asks to deploy an application.
+
+ 1. Run the deploy script with the version and environment parameters.
+ """),
+)
+
+
+@deployment_skill.script
+def deploy(version: str, environment: str = "staging") -> str:
+ """Deploy the application to the specified environment."""
+ return f"Deployed version {version} to {environment}"
+
+
+async def main() -> None:
+ """Run the skill script approval demo."""
+ endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
+ deployment = os.environ.get("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "gpt-4o-mini")
+
+ client = AzureOpenAIResponsesClient(
+ project_endpoint=endpoint,
+ deployment_name=deployment,
+ credential=AzureCliCredential(),
+ )
+
+ # Create the skills provider with script approval enabled
+ skills_provider = SkillsProvider(
+ skills=[deployment_skill],
+ require_script_approval=True,
+ )
+
+ async with Agent(
+ client=client,
+ instructions="You are a deployment assistant. Use the deployment skill to deploy applications.",
+ context_providers=[skills_provider],
+ ) as agent:
+ session = agent.create_session()
+
+ print("Starting agent with skill script approval enabled...")
+ print("-" * 60)
+
+ # Step 1: Send the user request — the agent will try to call the script
+ query = "Deploy the latest application version 2.5.0 to the production environment"
+ print(f"User: {query}")
+ result = await agent.run(query, session=session)
+
+ # Step 2: Handle approval requests (with sessions, context is
+ # maintained automatically — just send the approval response)
+ while result.user_input_requests:
+ for request in result.user_input_requests:
+ print(f"\nApproval needed:")
+ print(f" Function: {request.function_call.name}") # type: ignore[union-attr]
+ print(f" Arguments: {request.function_call.arguments}") # type: ignore[union-attr]
+
+ # In a real application, prompt the user here
+ approved = True # Change to False to see rejection
+ print(f" Decision: {'Approved' if approved else 'Rejected'}")
+
+ # Send the approval response — session preserves conversation history
+ approval_response = request.to_function_approval_response(approved=approved)
+ result = await agent.run(approval_response, session=session)
+
+ print(f"\nAgent: {result}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
+"""
+Sample output:
+
+Starting agent with skill script approval enabled...
+------------------------------------------------------------
+User: Deploy version 2.5.0 to production
+
+Approval needed:
+ Function: run_skill_script
+ Arguments: {"skill_name": "deployment", "script_name": "deploy", ...}
+ Decision: Approved
+
+Agent: Successfully deployed version 2.5.0 to production.
+"""
diff --git a/python/samples/02-agents/skills/subprocess_script_runner.py b/python/samples/02-agents/skills/subprocess_script_runner.py
new file mode 100644
index 0000000000..1d38bae754
--- /dev/null
+++ b/python/samples/02-agents/skills/subprocess_script_runner.py
@@ -0,0 +1,75 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Sample subprocess-based skill script runner.
+
+Executes file-based skill scripts as local Python subprocesses.
+This is provided for demonstration purposes only.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+from agent_framework import Skill, SkillScript
+
+
+def subprocess_script_runner(skill: Skill, script: SkillScript, 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.
+
+ Args:
+ skill: The skill that owns the script.
+ script: The 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
+ if not script_path.is_file():
+ return f"Error: Script file not found: {script_path}"
+
+ cmd = [sys.executable, str(script_path)]
+
+ # Convert args dict to CLI flags
+ if args:
+ for key, value in args.items():
+ if isinstance(value, bool):
+ if value:
+ cmd.append(f"--{key}")
+ elif value is not None:
+ cmd.append(f"--{key}")
+ cmd.append(str(value))
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ cwd=str(script_path.parent),
+ )
+
+ output = result.stdout
+ if result.stderr:
+ output += f"\nStderr:\n{result.stderr}"
+ if result.returncode != 0:
+ output += f"\nScript exited with code {result.returncode}"
+
+ return output.strip() or "(no output)"
+
+ except subprocess.TimeoutExpired:
+ return f"Error: Script '{script.name}' timed out after 30 seconds."
+ except OSError as e:
+ return f"Error: Failed to execute script '{script.name}': {e}"