diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 48ef906ab3a..6945a1e05db 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -74,7 +74,7 @@ agent_framework/ - **`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 `ContextProvider`) 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. +- **`SkillsProvider`** - Context provider (extends `ContextProvider`) 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. All three tools it exposes (`load_skill`, `read_skill_resource`, `run_skill_script`) are registered with `approval_mode="always_require"`, so every skill operation needs approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `SkillsProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (`load_skill`, `read_skill_resource`) while still prompting for `run_skill_script`, and `SkillsProvider.all_tools_auto_approval_rule` approves every skill tool including script execution. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. The tool names are also exposed as class constants (`LOAD_SKILL_TOOL_NAME`, `READ_SKILL_RESOURCE_TOOL_NAME`, `RUN_SKILL_SCRIPT_TOOL_NAME`). ### Model Context Protocol (`_mcp.py`) diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index aeee8df6476..eade6e2df0d 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -67,6 +67,7 @@ from ._agents import SupportsAgentRun from ._sessions import AgentSession, SessionContext + from ._types import Content logger = logging.getLogger(__name__) @@ -1790,6 +1791,17 @@ class SkillsProvider(ContextProvider): and file-based resource reads are guarded against path traversal and symlink escape. Only use skills from trusted sources. + **Tool approval:** every tool exposed by this provider + (``load_skill``, ``read_skill_resource``, and ``run_skill_script``) is + registered with ``approval_mode="always_require"``, so each skill operation + needs approval. To run unattended, pass one of the static + auto-approval rules to :class:`~agent_framework.ToolApprovalMiddleware` (via + ``auto_approval_rules``): + :meth:`read_only_tools_auto_approval_rule` approves only the read-only tools + (``load_skill`` and ``read_skill_resource``) while still prompting for + ``run_skill_script``, and :meth:`all_tools_auto_approval_rule` approves every + skill tool including script execution. + Examples: File-based factory (recommended for single-source file skills): @@ -1833,16 +1845,102 @@ class SkillsProvider(ContextProvider): Attributes: DEFAULT_SOURCE_ID: Default value for the ``source_id`` used by this provider. + LOAD_SKILL_TOOL_NAME: Name of the tool that loads a skill. + READ_SKILL_RESOURCE_TOOL_NAME: Name of the tool that reads a skill resource. + RUN_SKILL_SCRIPT_TOOL_NAME: Name of the tool that runs a skill script. """ DEFAULT_SOURCE_ID: ClassVar[str] = "agent_skills" + #: Name of the tool that loads the full content of a skill. + LOAD_SKILL_TOOL_NAME: ClassVar[str] = "load_skill" + #: Name of the tool that reads a resource associated with a skill. + READ_SKILL_RESOURCE_TOOL_NAME: ClassVar[str] = "read_skill_resource" + #: Name of the tool that runs a script associated with a skill. + RUN_SKILL_SCRIPT_TOOL_NAME: ClassVar[str] = "run_skill_script" + + #: Names of the tools that only read (never execute scripts from) the skills source. + _READ_ONLY_TOOL_NAMES: ClassVar[frozenset[str]] = frozenset({ + LOAD_SKILL_TOOL_NAME, + READ_SKILL_RESOURCE_TOOL_NAME, + }) + + #: Names of all tools exposed by this provider. + _ALL_TOOL_NAMES: ClassVar[frozenset[str]] = frozenset({ + LOAD_SKILL_TOOL_NAME, + READ_SKILL_RESOURCE_TOOL_NAME, + RUN_SKILL_SCRIPT_TOOL_NAME, + }) + + @staticmethod + def _is_local_tool_call(function_call: Content) -> bool: + """Return whether a function call targets this provider's local tools. + + Hosted-tool calls carry a ``server_label`` in their + ``additional_properties`` and are a separate server-scoped approval + boundary that must be passed through untouched (see + :func:`agent_framework._tools._is_hosted_tool_approval`). These rules + only ever auto-approve the provider's own local tools, so any call that + carries a ``server_label`` is rejected even if its name collides with a + skill tool name. + """ + return not function_call.additional_properties.get("server_label") + + @staticmethod + def read_only_tools_auto_approval_rule(function_call: Content) -> bool: + """Auto-approval rule that approves only the read-only skill tools. + + The tools exposed by :class:`SkillsProvider` always require approval. + Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via + ``auto_approval_rules``) to automatically approve the tools that read + skill content (``load_skill`` and ``read_skill_resource``), while still + prompting for script execution (``run_skill_script``). + + Hosted-tool calls (those carrying a ``server_label``) are never + auto-approved, even when their name matches a skill tool, so the rule + stays scoped to this provider's local tools. + + Args: + function_call: The pending ``function_call`` content. + + Returns: + ``True`` for read-only skill tools, ``False`` otherwise so that + subsequent rules continue to be evaluated. + """ + return ( + SkillsProvider._is_local_tool_call(function_call) + and function_call.name in SkillsProvider._READ_ONLY_TOOL_NAMES + ) + + @staticmethod + def all_tools_auto_approval_rule(function_call: Content) -> bool: + """Auto-approval rule that approves every skill tool. + + The tools exposed by :class:`SkillsProvider` always require approval. + Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via + ``auto_approval_rules``) to automatically approve every skill tool, + including the script execution tool (``run_skill_script``). + + Hosted-tool calls (those carrying a ``server_label``) are never + auto-approved, even when their name matches a skill tool, so the rule + stays scoped to this provider's local tools. + + Args: + function_call: The pending ``function_call`` content. + + Returns: + ``True`` for any skill tool, ``False`` otherwise so that subsequent + rules continue to be evaluated. + """ + return ( + SkillsProvider._is_local_tool_call(function_call) and function_call.name in SkillsProvider._ALL_TOOL_NAMES + ) + def __init__( self, source: SkillsSource | Sequence[Skill] | Skill, *, instruction_template: str | None = None, - require_script_approval: bool = False, disable_caching: bool = False, source_id: str | None = None, ) -> None: @@ -1869,22 +1967,21 @@ def __init__( When omitted, those instructions are simply not included in the rendered prompt (the corresponding tools are still registered). Uses a built-in template when ``None``. - 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. disable_caching: When ``True``, rebuilds tools and instructions from the source on every invocation instead of caching after the first build. Defaults to ``False``. source_id: Unique identifier for this provider instance. + + .. note:: + + All skill tools require approval. To approve them + automatically, pass :meth:`read_only_tools_auto_approval_rule` or + :meth:`all_tools_auto_approval_rule` to + :class:`~agent_framework.ToolApprovalMiddleware`. See + ``samples/02-agents/skills/skills_auto_approval/skills_auto_approval.py`` + for the auto-approval pattern and + ``samples/02-agents/skills/script_approval/script_approval.py`` for + the manual approval loop. """ super().__init__(source_id or self.DEFAULT_SOURCE_ID) @@ -1903,7 +2000,6 @@ def __init__( self._source = source self._instruction_template = instruction_template - self._require_script_approval = require_script_approval self._disable_caching = disable_caching # Lazy-initialized via _get_or_create_context / _create_context @@ -1921,7 +2017,6 @@ def from_paths( script_filter: Callable[[str, str], bool] | None = None, resource_filter: Callable[[str, str], bool] | None = None, instruction_template: str | None = None, - require_script_approval: bool = False, disable_caching: bool = False, source_id: str | None = None, ) -> _TSkillsProvider: @@ -1957,18 +2052,6 @@ def from_paths( instruction_template: Custom system-prompt template for advertising skills. Must contain a ``{skills}`` placeholder. Uses a built-in template when ``None``. - 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. disable_caching: When ``True``, rebuilds tools and instructions from the source on every invocation instead of caching after the first build. @@ -1976,6 +2059,13 @@ def from_paths( Returns: A configured :class:`SkillsProvider`. + + .. note:: + + All skill tools require approval. To approve them + automatically, pass :meth:`read_only_tools_auto_approval_rule` or + :meth:`all_tools_auto_approval_rule` to + :class:`~agent_framework.ToolApprovalMiddleware`. """ source = DeduplicatingSkillsSource( FileSkillsSource( @@ -1991,7 +2081,6 @@ def from_paths( return cls( source, instruction_template=instruction_template, - require_script_approval=require_script_approval, disable_caching=disable_caching, source_id=source_id, ) @@ -2083,10 +2172,7 @@ async def _create_context(self) -> tuple[Sequence[Skill], str | None, list[Funct skills=skills, ) - tools = self._create_tools( - skills=skills, - require_script_approval=self._require_script_approval, - ) + tools = self._create_tools(skills=skills) return skills, instructions, tools @@ -2153,18 +2239,19 @@ async def before_run( def _create_tools( self, skills: Sequence[Skill], - require_script_approval: bool = False, ) -> list[FunctionTool]: """Create the tool definitions for skill interaction. Always includes ``load_skill``, ``read_skill_resource``, and - ``run_skill_script``. + ``run_skill_script``. Every tool is registered with + ``approval_mode="always_require"`` so each skill operation needs + approval; use :meth:`read_only_tools_auto_approval_rule` or + :meth:`all_tools_auto_approval_rule` with + :class:`~agent_framework.ToolApprovalMiddleware` to approve them + automatically. Args: skills: The skills to bind to tool handlers. - require_script_approval: When ``True``, the - ``run_skill_script`` tool pauses for user approval - before each invocation. Returns: A list of :class:`FunctionTool` instances. @@ -2183,9 +2270,10 @@ async def _run_script( return [ FunctionTool( - name="load_skill", + name=self.LOAD_SKILL_TOOL_NAME, description="Loads the full instructions for a specific skill.", func=_load, + approval_mode="always_require", input_model={ "type": "object", "properties": { @@ -2195,9 +2283,10 @@ async def _run_script( }, ), FunctionTool( - name="read_skill_resource", + name=self.READ_SKILL_RESOURCE_TOOL_NAME, description=("Reads a resource associated with a skill, such as references, assets, or dynamic data."), func=_read_resource, + approval_mode="always_require", input_model={ "type": "object", "properties": { @@ -2211,10 +2300,10 @@ async def _run_script( }, ), FunctionTool( - name="run_skill_script", + name=self.RUN_SKILL_SCRIPT_TOOL_NAME, description="Runs a script associated with a skill.", func=_run_script, - approval_mode="always_require" if require_script_approval else "never_require", + approval_mode="always_require", input_model={ "type": "object", "properties": { diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index d9789709f77..be6adbe1501 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -16,6 +16,7 @@ from agent_framework import ( AggregatingSkillsSource, ClassSkill, + Content, DeduplicatingSkillsSource, FileSkill, FileSkillScript, @@ -3772,37 +3773,72 @@ async def test_tool_schema_args_description_mentions_key_format(self) -> None: args_desc = run_tool.parameters()["properties"]["args"]["description"] assert "script implementation or configured runner" in args_desc - async def test_require_script_approval_sets_approval_mode(self) -> None: - """When require_script_approval=True, the run_skill_script tool has approval_mode='always_require'.""" - skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body") - skill._scripts.append(InlineSkillScript(name="s1", function=lambda: None)) - - provider = SkillsProvider([skill], require_script_approval=True) - await _init_provider(provider) - run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - assert run_tool.approval_mode == "always_require" - - async def test_require_script_approval_false_by_default(self) -> None: - """By default, the run_skill_script tool has approval_mode='never_require'.""" + async def test_all_tools_require_approval_by_default(self) -> None: + """All skill tools have approval_mode='always_require' by default.""" skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body") skill._scripts.append(InlineSkillScript(name="s1", function=lambda: None)) provider = SkillsProvider([skill]) await _init_provider(provider) - run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - assert run_tool.approval_mode == "never_require" - - async def test_require_script_approval_does_not_affect_other_tools(self) -> None: - """Non-script tools should never require approval.""" - skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body") - skill._scripts.append(InlineSkillScript(name="s1", function=lambda: None)) + tools = [t for t in _ctx(provider)[2] if hasattr(t, "name")] + assert {t.name for t in tools} == {"load_skill", "read_skill_resource", "run_skill_script"} + for t in tools: + assert t.approval_mode == "always_require" + + async def test_tool_name_constants(self) -> None: + """The provider exposes its tool names as class constants.""" + assert SkillsProvider.LOAD_SKILL_TOOL_NAME == "load_skill" + assert SkillsProvider.READ_SKILL_RESOURCE_TOOL_NAME == "read_skill_resource" + assert SkillsProvider.RUN_SKILL_SCRIPT_TOOL_NAME == "run_skill_script" + + async def test_read_only_tools_auto_approval_rule(self) -> None: + """The read-only rule approves only load_skill and read_skill_resource.""" + approved = { + SkillsProvider.LOAD_SKILL_TOOL_NAME, + SkillsProvider.READ_SKILL_RESOURCE_TOOL_NAME, + } + rejected = { + SkillsProvider.RUN_SKILL_SCRIPT_TOOL_NAME, + "some_other_tool", + } + for name in approved: + call = Content("function_call", call_id="c1", name=name, arguments="{}") + assert SkillsProvider.read_only_tools_auto_approval_rule(call) is True + for name in rejected: + call = Content("function_call", call_id="c1", name=name, arguments="{}") + assert SkillsProvider.read_only_tools_auto_approval_rule(call) is False + # A hosted tool with the same name (carrying a server_label) is NOT auto-approved. + for name in approved: + hosted = Content( + "function_call", + call_id="c1", + name=name, + arguments="{}", + additional_properties={"server_label": "remote"}, + ) + assert SkillsProvider.read_only_tools_auto_approval_rule(hosted) is False + + async def test_all_tools_auto_approval_rule(self) -> None: + """The all-tools rule approves every skill tool but nothing else.""" + for name in ( + SkillsProvider.LOAD_SKILL_TOOL_NAME, + SkillsProvider.READ_SKILL_RESOURCE_TOOL_NAME, + SkillsProvider.RUN_SKILL_SCRIPT_TOOL_NAME, + ): + call = Content("function_call", call_id="c1", name=name, arguments="{}") + assert SkillsProvider.all_tools_auto_approval_rule(call) is True + # A hosted tool with the same name (carrying a server_label) is NOT auto-approved. + hosted = Content( + "function_call", + call_id="c1", + name=name, + arguments="{}", + additional_properties={"server_label": "remote"}, + ) + assert SkillsProvider.all_tools_auto_approval_rule(hosted) is False - provider = SkillsProvider([skill], require_script_approval=True) - await _init_provider(provider) - other_tools = [t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name != "run_skill_script"] - assert len(other_tools) == 2 - for t in other_tools: - assert t.approval_mode == "never_require" + unrelated = Content("function_call", call_id="c1", name="some_other_tool", arguments="{}") + assert SkillsProvider.all_tools_auto_approval_rule(unrelated) is False async def test_code_script_exception_returns_error(self) -> None: """A code script function that raises should return an error string.""" @@ -5424,13 +5460,12 @@ async def test_file_source_with_script_runner(self, tmp_path: Path) -> None: assert any(hasattr(t, "name") and t.name == "run_skill_script" for t in _ctx(provider)[2]) async def test_script_approval_on_provider(self) -> None: - """SkillsProvider with require_script_approval sets the approval mode.""" + """SkillsProvider tools all require approval regardless of source type.""" skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body") skill._scripts.append(InlineSkillScript(name="s1", function=lambda: None)) provider = SkillsProvider( DeduplicatingSkillsSource(InMemorySkillsSource([skill])), - require_script_approval=True, ) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") @@ -5537,11 +5572,11 @@ async def test_init_with_skills_and_options(self) -> None: skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="Test"), instructions="Body") provider = SkillsProvider( [skill], - require_script_approval=True, + disable_caching=True, source_id="custom", ) assert provider.source_id == "custom" - assert provider._require_script_approval is True + assert provider._disable_caching is True def test_init_with_source_creates_provider(self) -> None: """Constructor with SkillsSource returns a SkillsProvider instance.""" diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_skills.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_skills.py index 73b9dd1e89c..78c00b1a2d4 100644 --- a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_skills.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_skills.py @@ -5,7 +5,7 @@ from collections.abc import Generator import httpx -from agent_framework import Agent, MCPSkillsSource, SkillsProvider +from agent_framework import Agent, MCPSkillsSource, SkillsProvider, ToolApprovalMiddleware from agent_framework.foundry import FoundryChatClient from azure.core.credentials import TokenCredential from azure.identity import AzureCliCredential, get_bearer_token_provider @@ -75,11 +75,13 @@ async def main() -> None: name="ToolboxMCPSkillsAgent", instructions="You are a helpful assistant. Use available skills to answer the user.", context_providers=[skills_provider], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[SkillsProvider.all_tools_auto_approval_rule])], ) as agent: query = input("User: ").strip() # noqa: ASYNC250 if not query: return - response = await agent.run(query) + session = agent.create_session() + response = await agent.run(query, session=session) print(f"Assistant: {response.text}") diff --git a/python/samples/02-agents/skills/README.md b/python/samples/02-agents/skills/README.md index 53a2167531f..6b91c8eafad 100644 --- a/python/samples/02-agents/skills/README.md +++ b/python/samples/02-agents/skills/README.md @@ -13,7 +13,8 @@ Start with file-based or code-defined skills, then explore combining them and ad | [**class_based_skill**](class_based_skill/) | Define skills as Python classes using `ClassSkill` with `@ClassSkill.resource` and `@ClassSkill.script` decorators for auto-discovery. Uses a class-based unit-converter skill. | | [**mixed_skills**](mixed_skills/) | Combine code-defined, class-based, and file-based skills in a single agent. Uses a code-defined volume-converter, a class-based temperature-converter, and a file-based unit-converter. | | [**mcp_based_skill**](mcp_based_skill/) | Discover skills served over the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) via `MCPSkillsSource`. Connects to a remote MCP server that exposes skills as `skill://...` resources following the SEP-2640 convention. | -| [**script_approval**](script_approval/) | Require human-in-the-loop approval before executing skill scripts | +| [**script_approval**](script_approval/) | Require manual human-in-the-loop approval before running skill tools (the default). | +| [**skills_auto_approval**](skills_auto_approval/) | Configure auto-approval rules with `ToolApprovalMiddleware` so read-only skill tools are approved automatically while script execution still prompts. | ## Key Concepts diff --git a/python/samples/02-agents/skills/class_based_skill/class_based_skill.py b/python/samples/02-agents/skills/class_based_skill/class_based_skill.py index cc2090b5f15..dd32fe8c316 100644 --- a/python/samples/02-agents/skills/class_based_skill/class_based_skill.py +++ b/python/samples/02-agents/skills/class_based_skill/class_based_skill.py @@ -10,7 +10,7 @@ # warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) from textwrap import dedent -from agent_framework import Agent, ClassSkill, SkillFrontmatter, SkillsProvider +from agent_framework import Agent, ClassSkill, SkillFrontmatter, SkillsProvider, ToolApprovalMiddleware from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -119,15 +119,21 @@ async def main() -> None: # Instantiate the class-based skill and pass it to the provider unit_converter = UnitConverterSkill() + # All skill tools require approval by default; auto-approve them so the + # sample runs unattended. See the script_approval / skills_auto_approval + # samples for interactive and selective approval handling. async with Agent( client=client, instructions="You are a helpful assistant that can convert units.", context_providers=[SkillsProvider(unit_converter)], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[SkillsProvider.all_tools_auto_approval_rule])], ) as agent: print("Converting units with class-based skills") print("-" * 60) + session = agent.create_session() response = await agent.run( - "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?" + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?", + session=session, ) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py index 42fd0a38de5..f1c07d7fe0b 100644 --- a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py +++ b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py @@ -11,7 +11,14 @@ from textwrap import dedent from typing import Any -from agent_framework import Agent, InlineSkill, InlineSkillResource, SkillFrontmatter, SkillsProvider +from agent_framework import ( + Agent, + InlineSkill, + InlineSkillResource, + SkillFrontmatter, + SkillsProvider, + ToolApprovalMiddleware, +) from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -144,16 +151,22 @@ async def main() -> None: ) # Create the skills provider with the code-defined skill and pass it to the agent + # All skill tools require approval by default; auto-approve them so the + # sample runs unattended. See the script_approval / skills_auto_approval + # samples for interactive and selective approval handling. async with Agent( client=client, instructions="You are a helpful assistant that can convert units.", context_providers=[SkillsProvider(unit_converter_skill)], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[SkillsProvider.all_tools_auto_approval_rule])], ) as agent: print("Converting units") print("-" * 60) + session = agent.create_session() response = await agent.run( "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?", function_invocation_kwargs={"precision": 2}, + session=session, ) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py index 43e98dcfec6..98525c7023f 100644 --- a/python/samples/02-agents/skills/file_based_skill/file_based_skill.py +++ b/python/samples/02-agents/skills/file_based_skill/file_based_skill.py @@ -10,7 +10,7 @@ # warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) from pathlib import Path -from agent_framework import Agent, SkillsProvider +from agent_framework import Agent, SkillsProvider, ToolApprovalMiddleware from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -64,18 +64,23 @@ async def main() -> None: script_runner=subprocess_script_runner, ) - # Create the agent with skills + # Create the agent with skills. All skill tools require approval by + # default; auto-approve them so the sample runs unattended. See the + # script_approval / skills_auto_approval samples for approval handling. async with Agent( client=client, instructions="You are a helpful assistant.", context_providers=[skills_provider], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[SkillsProvider.all_tools_auto_approval_rule])], ) 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) + session = agent.create_session() response = await agent.run( - "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?" + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?", + session=session, ) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/mcp_based_skill/mcp_based_skill.py b/python/samples/02-agents/skills/mcp_based_skill/mcp_based_skill.py index a85841cd4d0..13b9178e82e 100644 --- a/python/samples/02-agents/skills/mcp_based_skill/mcp_based_skill.py +++ b/python/samples/02-agents/skills/mcp_based_skill/mcp_based_skill.py @@ -7,7 +7,7 @@ # using the sample's Skills APIs. # import warnings # warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) -from agent_framework import Agent, MCPSkillsSource, SkillsProvider +from agent_framework import Agent, MCPSkillsSource, SkillsProvider, ToolApprovalMiddleware from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -63,11 +63,13 @@ async def main() -> None: client=client, instructions="You are a helpful assistant. Use available skills to answer the user.", context_providers=[skills_provider], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[SkillsProvider.all_tools_auto_approval_rule])], ) as agent: query = input("User: ").strip() # noqa: ASYNC250 if not query: return - response = await agent.run(query) + session = agent.create_session() + response = await agent.run(query, session=session) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py index 82a203d72ed..2d85daf1f71 100644 --- a/python/samples/02-agents/skills/mixed_skills/mixed_skills.py +++ b/python/samples/02-agents/skills/mixed_skills/mixed_skills.py @@ -23,6 +23,7 @@ InMemorySkillsSource, SkillFrontmatter, SkillsProvider, + ToolApprovalMiddleware, ) from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential @@ -213,20 +214,25 @@ async def main() -> None: ) ) - # Run the agent + # Run the agent. All skill tools require approval by default; auto-approve + # them so the sample runs unattended. See the script_approval / + # skills_auto_approval samples for approval handling. async with Agent( client=client, instructions="You are a helpful assistant that can convert units, volumes, and temperatures.", context_providers=[skills_provider], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[SkillsProvider.all_tools_auto_approval_rule])], ) as agent: # Ask the agent to use all three skills print("Converting with mixed skills (file + code + class)") print("-" * 60) + session = agent.create_session() response = await agent.run( "I need three conversions: " "1) How many kilometers is a marathon (26.2 miles)? " "2) How many liters is a 5-gallon bucket? " - "3) What is 98.6°F in Celsius?" + "3) What is 98.6°F in Celsius?", + session=session, ) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/script_approval/README.md b/python/samples/02-agents/skills/script_approval/README.md index 07dd37cb65a..a2a24667b23 100644 --- a/python/samples/02-agents/skills/script_approval/README.md +++ b/python/samples/02-agents/skills/script_approval/README.md @@ -1,24 +1,33 @@ -# Script Approval — Human-in-the-Loop for Skill Scripts +# Skill Tool Approval — Human-in-the-Loop for Skill Tools -This sample demonstrates how to require **human approval** before executing skill scripts using the `require_script_approval=True` option on `SkillsProvider`. +This sample demonstrates the **manual human-in-the-loop** approval pattern for +skill tools. Every tool exposed by `SkillsProvider` (`load_skill`, +`read_skill_resource`, and `run_skill_script`) requires host approval by +default, so the agent pauses and returns approval requests that your +application approves or rejects. ## How It Works -When `require_script_approval=True` is set, the agent pauses before executing any skill script and returns approval requests instead: +By default, skill tools require approval. The agent pauses before running any of +them and returns approval requests instead: -1. The agent tries to call `run_skill_script` — execution is paused +1. The agent tries to call a skill tool (e.g. `load_skill` or `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 +6. If approved, the tool runs; if rejected, the agent receives an error ## Key Components -- **`require_script_approval=True`** — Gates all script execution on human approval +- **Approval-by-default** — All skill tools require host approval; no extra configuration is needed - **`result.user_input_requests`** — Contains pending approval requests after `agent.run()` - **`request.to_function_approval_response()`** — Creates an approval or rejection response +To approve skill tools automatically instead of prompting for each one, use +`ToolApprovalMiddleware` with one of the static auto-approval rules — see the +[Skills Auto-Approval Sample](../skills_auto_approval/). + ## Running the Sample ### Prerequisites @@ -29,7 +38,7 @@ When `require_script_approval=True` is set, the agent pauses before executing an Set the required environment variables in a `.env` file (see `python/.env.example`): - `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `FOUNDRY_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication @@ -44,6 +53,7 @@ uv run samples/02-agents/skills/script_approval/script_approval.py ## Learn More +- [Skills Auto-Approval Sample](../skills_auto_approval/) - [File-Based Skills Sample](../file_based_skill/) - [Code-Defined Skills Sample](../code_defined_skill/) - [Mixed Skills Sample](../mixed_skills/) diff --git a/python/samples/02-agents/skills/script_approval/script_approval.py b/python/samples/02-agents/skills/script_approval/script_approval.py index 179b8008182..f0ed988d75e 100644 --- a/python/samples/02-agents/skills/script_approval/script_approval.py +++ b/python/samples/02-agents/skills/script_approval/script_approval.py @@ -9,29 +9,35 @@ # warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) from textwrap import dedent -from agent_framework import Agent, InlineSkill, SkillFrontmatter, SkillsProvider +from agent_framework import Agent, Content, InlineSkill, Message, SkillFrontmatter, SkillsProvider from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv """ -Skill Script Approval — Require human approval before executing skill scripts +Skill Tool Approval — Require human approval before running skill tools -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. +Every tool exposed by :class:`SkillsProvider` (``load_skill``, +``read_skill_resource``, and ``run_skill_script``) requires host approval by +default. This sample shows the manual human-in-the-loop pattern: the agent +pauses and returns approval requests, and the application approves or rejects +each one before the agent continues. 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 +2. Because skill tools require approval by default, the agent pauses and returns approval requests in ``result.user_input_requests`` instead of executing - scripts immediately. + tools 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. + and the agent continues — running the tool if approved, or receiving an + error if rejected. + +To approve skill tools automatically instead of prompting, use +``ToolApprovalMiddleware`` with one of the static auto-approval rules — see +``samples/02-agents/skills/skills_auto_approval/skills_auto_approval.py``. Prerequisites: - FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. @@ -71,10 +77,9 @@ async def main() -> None: credential=AzureCliCredential(), ) - # Create the skills provider with script approval enabled + # Create the skills provider. All skill tools require approval by default. skills_provider = SkillsProvider( source=[deployment_skill], - require_script_approval=True, ) async with Agent( @@ -84,7 +89,7 @@ async def main() -> None: ) as agent: session = agent.create_session() - print("Starting agent with skill script approval enabled...") + print("Starting agent with skill tool approval (the default)...") print("-" * 60) # Step 1: Send the user request — the agent will try to call the script @@ -93,10 +98,14 @@ async def main() -> None: result = await agent.run(query, session=session) # Step 2: Handle approval requests (with sessions, context is - # maintained automatically — just send the approval response) + # maintained automatically). Collect a response for every request and + # send them in one run so the loop always makes progress. while result.user_input_requests: + approval_responses: list[Content] = [] for request in result.user_input_requests: if request.function_call is None: + # Not a function-approval request; reject it so the run can proceed. + approval_responses.append(request.to_function_approval_response(approved=False)) continue print("\nApproval needed:") print(f" Function: {request.function_call.name}") @@ -105,10 +114,10 @@ async def main() -> None: # In a real application, prompt the user here approved = True # Change to False to see rejection print(f" Decision: {'Approved' if approved else 'Rejected'}") + approval_responses.append(request.to_function_approval_response(approved=approved)) - # 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) + # Send the approval responses — session preserves conversation history + result = await agent.run(Message(role="user", contents=approval_responses), session=session) print(f"\nAgent: {result}") @@ -119,9 +128,14 @@ async def main() -> None: """ Sample output: -Starting agent with skill script approval enabled... +Starting agent with skill tool approval (the default)... ------------------------------------------------------------ -User: Deploy version 2.5.0 to production +User: Deploy the latest application version 2.5.0 to the production environment + +Approval needed: + Function: load_skill + Arguments: {"skill_name": "deployment"} + Decision: Approved Approval needed: Function: run_skill_script diff --git a/python/samples/02-agents/skills/skill_filtering/skill_filtering.py b/python/samples/02-agents/skills/skill_filtering/skill_filtering.py index 120f179e12f..35fcd584957 100644 --- a/python/samples/02-agents/skills/skill_filtering/skill_filtering.py +++ b/python/samples/02-agents/skills/skill_filtering/skill_filtering.py @@ -11,6 +11,7 @@ FileSkillsSource, FilteringSkillsSource, SkillsProvider, + ToolApprovalMiddleware, ) from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential @@ -81,15 +82,20 @@ async def main() -> None: skills_provider = SkillsProvider(source) - # 3. Run the agent — it can only see the volume-converter skill + # 3. Run the agent — it can only see the volume-converter skill. All skill + # tools require approval by default; auto-approve them so the sample runs + # unattended. See the script_approval / skills_auto_approval samples for + # approval handling. async with Agent( client=client, instructions="You are a helpful assistant that can convert units.", context_providers=[skills_provider], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[SkillsProvider.all_tools_auto_approval_rule])], ) as agent: print("Skill filtering demo") print("-" * 60) - response = await agent.run("How many liters is a 5-gallon bucket?") + session = agent.create_session() + response = await agent.run("How many liters is a 5-gallon bucket?", session=session) print(f"Agent: {response}\n") diff --git a/python/samples/02-agents/skills/skills_auto_approval/README.md b/python/samples/02-agents/skills/skills_auto_approval/README.md new file mode 100644 index 00000000000..ba324ee349a --- /dev/null +++ b/python/samples/02-agents/skills/skills_auto_approval/README.md @@ -0,0 +1,62 @@ +# Skills Auto-Approval — Configure Auto-Approval Rules for Skill Tools + +This sample demonstrates how to configure **auto-approval rules** for skill +tools using `ToolApprovalMiddleware`. Every tool exposed by `SkillsProvider` +(`load_skill`, `read_skill_resource`, and `run_skill_script`) requires host +approval by default. Auto-approval rules let you selectively bypass the approval +prompt for safe operations. + +## How It Works + +1. A code-defined unit-converter skill (with a resource and a script) is registered via `SkillsProvider`. +2. The agent installs `ToolApprovalMiddleware` with `SkillsProvider.read_only_tools_auto_approval_rule`. +3. The read-only tools (`load_skill`, `read_skill_resource`) are approved automatically. +4. `run_skill_script` still requires explicit approval and is handled with the standard `result.user_input_requests` loop. + +## Auto-Approval Rules + +`SkillsProvider` exposes two static rules to pass to `ToolApprovalMiddleware(auto_approval_rules=[...])`: + +- **`SkillsProvider.read_only_tools_auto_approval_rule`** — approves only the read-only tools (`load_skill`, `read_skill_resource`), while still prompting for `run_skill_script`. +- **`SkillsProvider.all_tools_auto_approval_rule`** — approves every skill tool, including `run_skill_script` (no manual approval loop needed). + +Both rules reject any call carrying a `server_label`, so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. + +> **Note:** To use auto-approval rules, the agent must have `ToolApprovalMiddleware` in its middleware stack. + +## Key Components + +- **`ToolApprovalMiddleware(auto_approval_rules=[...])`** — Drives the approval handshake and applies the rules +- **`SkillsProvider.read_only_tools_auto_approval_rule`** — Auto-approves read-only skill tools +- **`SkillsProvider.all_tools_auto_approval_rule`** — Auto-approves all skill tools +- **`SkillsProvider.LOAD_SKILL_TOOL_NAME` / `READ_SKILL_RESOURCE_TOOL_NAME` / `RUN_SKILL_SCRIPT_TOOL_NAME`** — Tool-name constants for building custom rules + +## Running the Sample + +### Prerequisites +- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model (e.g. `gpt-4o-mini`) + +### Environment Variables + +Set the required environment variables in a `.env` file (see `python/.env.example`): + +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: The name of your model deployment (defaults to `gpt-4o-mini`) + +### Authentication + +This sample uses `AzureCliCredential` for authentication. Run `az login` in your terminal before running the sample. + +### Run + +```bash +cd python +uv run samples/02-agents/skills/skills_auto_approval/skills_auto_approval.py +``` + +## Learn More + +- [Skill Tool Approval Sample](../script_approval/) — manual human-in-the-loop approval +- [Code-Defined Skills Sample](../code_defined_skill/) +- [File-Based Skills Sample](../file_based_skill/) +- [Agent Skills Specification](https://agentskills.io/) diff --git a/python/samples/02-agents/skills/skills_auto_approval/skills_auto_approval.py b/python/samples/02-agents/skills/skills_auto_approval/skills_auto_approval.py new file mode 100644 index 00000000000..5f69e92a04a --- /dev/null +++ b/python/samples/02-agents/skills/skills_auto_approval/skills_auto_approval.py @@ -0,0 +1,196 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import json +import os + +# Uncomment this filter to suppress the experimental Skills warning before +# using the sample's Skills APIs. +# import warnings # isort: skip +# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning) +from textwrap import dedent +from typing import Any + +from agent_framework import ( + Agent, + Content, + InlineSkill, + InlineSkillResource, + Message, + SkillFrontmatter, + SkillsProvider, + ToolApprovalMiddleware, +) +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +""" +Skills Auto-Approval — Configure auto-approval rules for skill tools + +Every tool exposed by :class:`SkillsProvider` (``load_skill``, +``read_skill_resource``, and ``run_skill_script``) requires host approval by +default. Rather than prompting for every call, this sample uses +:class:`ToolApprovalMiddleware` with a static auto-approval rule so that the +read-only tools are approved automatically while script execution still +requires explicit user approval. + +How it works: +1. A code-defined unit-converter skill (with a resource and a script) is + registered via SkillsProvider. +2. The agent installs ``ToolApprovalMiddleware`` with + ``SkillsProvider.read_only_tools_auto_approval_rule``. This auto-approves + ``load_skill`` and ``read_skill_resource`` while still prompting for + ``run_skill_script``. +3. The application handles the remaining ``run_skill_script`` approval requests + via the standard ``result.user_input_requests`` loop. + +Available auto-approval rules: +- ``SkillsProvider.read_only_tools_auto_approval_rule`` — approves only the + read-only tools (``load_skill``, ``read_skill_resource``). +- ``SkillsProvider.all_tools_auto_approval_rule`` — approves every skill tool, + including ``run_skill_script`` (no manual approval loop needed). + +To use auto-approval rules, the agent must have ``ToolApprovalMiddleware`` in +its middleware stack. + +Prerequisites: +- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- FOUNDRY_MODEL (defaults to "gpt-4o-mini"). +""" + +# Load environment variables from .env file +load_dotenv() + +# A code-defined unit-converter skill with a resource (read-only) and a script. +unit_converter_skill = InlineSkill( + frontmatter=SkillFrontmatter( + name="unit-converter", description="Convert between common units using a conversion factor" + ), + instructions=dedent("""\ + Use this skill when the user asks to convert between units. + + 1. Review the conversion-tables resource to find the factor for the + requested conversion. + 2. Use the convert script, passing the value and factor from the table. + """), + resources=[ + InlineSkillResource( + 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 | + """), + ), + ], +) + + +@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. + + Args: + value: The numeric value to convert. + factor: Conversion factor from the conversion table. + **kwargs: Runtime keyword arguments from ``agent.run()``. + + Returns: + JSON string with the inputs and converted result. + """ + result = round(value * factor, 2) + return json.dumps({"value": value, "factor": factor, "result": result}) + + +async def main() -> None: + """Run the skills auto-approval demo.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini") + + client = FoundryChatClient( + project_endpoint=endpoint, + model=deployment, + credential=AzureCliCredential(), + ) + + skills_provider = SkillsProvider(unit_converter_skill) + + # Install ToolApprovalMiddleware with the read-only auto-approval rule. + # load_skill and read_skill_resource are approved automatically; the agent + # only pauses for run_skill_script. + # + # To approve every skill tool without prompting, swap the rule for + # SkillsProvider.all_tools_auto_approval_rule (the manual approval loop + # below then becomes a no-op). + approval_middleware = ToolApprovalMiddleware( + auto_approval_rules=[SkillsProvider.read_only_tools_auto_approval_rule] + ) + + async with Agent( + client=client, + instructions="You are a helpful assistant that can convert units.", + context_providers=[skills_provider], + middleware=[approval_middleware], + ) as agent: + session = agent.create_session() + + print("Converting units with skill tools and read-only auto-approval") + print("-" * 60) + + query = "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?" + print(f"User: {query}") + result = await agent.run(query, session=session) + + # Read-only tools (load_skill, read_skill_resource) were auto-approved. + # Only run_skill_script reaches this loop and needs explicit approval. + # Collect a response for every request and send them in one run so the + # loop always makes progress. + while result.user_input_requests: + approval_responses: list[Content] = [] + for request in result.user_input_requests: + if request.function_call is None: + # Not a function-approval request; reject it so the run can proceed. + approval_responses.append(request.to_function_approval_response(approved=False)) + continue + print("\nApproval needed:") + print(f" Function: {request.function_call.name}") + print(f" Arguments: {request.function_call.arguments}") + + # In a real application, prompt the user here. + approved = True + print(f" Decision: {'Approved' if approved else 'Rejected'}") + approval_responses.append(request.to_function_approval_response(approved=approved)) + + result = await agent.run(Message(role="user", contents=approval_responses), session=session) + + print(f"\nAgent: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: + +Converting units with skill tools and read-only auto-approval +------------------------------------------------------------ +User: How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms? + +Approval needed: + Function: run_skill_script + Arguments: {"skill_name": "unit-converter", "script_name": "convert", ...} + Decision: Approved + +Agent: Here are your conversions: + +1. 26.2 miles -> 42.16 km (a marathon distance) +2. 75 kg -> 165.35 lbs +"""