mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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>
This commit is contained in:
committed by
GitHub
Unverified
parent
2f8fd5f82f
commit
23ebfbc937
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
</available_skills>
|
||||
|
||||
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<resources>\n{resource_lines}\n</resources>"
|
||||
|
||||
if skill.scripts:
|
||||
script_lines = "\n".join(_create_script_element(s) for s in skill.scripts)
|
||||
content += f"\n\n<scripts>\n{script_lines}\n</scripts>"
|
||||
|
||||
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" <resource {attrs}/>"
|
||||
|
||||
|
||||
def _create_script_element(script: SkillScript) -> str:
|
||||
"""Create an XML ``<script …>`` element from a :class:`SkillScript`.
|
||||
|
||||
When the script has a ``parameters_schema``, the element includes a
|
||||
``<parameters_schema>`` child element containing the JSON schema.
|
||||
Otherwise the element is self-closing.
|
||||
|
||||
Args:
|
||||
script: The script to create the element from.
|
||||
|
||||
Returns:
|
||||
An indented XML element string with ``name``, optional
|
||||
``description`` attributes, and an optional
|
||||
``<parameters_schema>`` child element.
|
||||
"""
|
||||
attrs = f'name="{xml_escape(script.name, quote=True)}"'
|
||||
if script.description:
|
||||
attrs += f' description="{xml_escape(script.description, quote=True)}"'
|
||||
if script.parameters_schema:
|
||||
params_json = xml_escape(json.dumps(script.parameters_schema), quote=False)
|
||||
return f" <script {attrs}>\n <parameters_schema>{params_json}</parameters_schema>\n </script>"
|
||||
return f" <script {attrs}/>"
|
||||
|
||||
|
||||
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 ``<skill>`` 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" <description>{xml_escape(skill.description)}</description>")
|
||||
lines.append(" </skill>")
|
||||
|
||||
return template.format(skills="\n".join(lines))
|
||||
return template.format(
|
||||
skills="\n".join(lines),
|
||||
runner_instructions=runner_instructions or "",
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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`)
|
||||
@@ -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/)
|
||||
@@ -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...
|
||||
"""
|
||||
@@ -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.
|
||||
-5
@@ -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 |
|
||||
-55
@@ -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.
|
||||
@@ -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/)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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/)
|
||||
@@ -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())
|
||||
@@ -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/)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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 <number> --factor <factor>` (e.g. `--value 26.2 --factor 1.60934`)
|
||||
3. Present the converted value clearly with both units
|
||||
+10
@@ -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 |
|
||||
+29
@@ -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()
|
||||
@@ -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/)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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 <number> --factor <factor>` (e.g. `--value 26.2 --factor 1.60934`)
|
||||
3. Present the converted value clearly with both units
|
||||
+10
@@ -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 |
|
||||
@@ -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()
|
||||
@@ -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/)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user