From be9c34010d17caf98e4eea8bece46788591f71b2 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:32:44 +0000 Subject: [PATCH 1/4] Require approvals for file-access and expose auto approval funcs for it --- python/packages/core/AGENTS.md | 2 +- .../agent_framework/_harness/_file_access.py | 115 ++++++++++++-- .../tests/core/test_harness_file_access.py | 68 +++++--- .../harness/harness_data_processing.py | 146 ++++++++++++++++++ .../02-agents/harness/working/sales.csv | 50 ++++++ 5 files changed, 339 insertions(+), 42 deletions(-) create mode 100644 python/samples/02-agents/harness/harness_data_processing.py create mode 100644 python/samples/02-agents/harness/working/sales.csv diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 9e0b9dca59..b553fd9253 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -98,7 +98,7 @@ agent_framework/ - **`InMemoryAgentFileStore`** - Dict-backed store suitable for tests and lightweight scenarios. - **`FileSystemAgentFileStore`** - Disk-backed store rooted under a configurable directory. Enforces relative-path normalization, root containment, and rejects symlink/reparse-point segments to prevent escape. - **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search_files`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers. -- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents. +- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). All six tools are registered with `approval_mode="always_require"`, so every file operation needs host approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `FileAccessProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (read, list files, list subdirectories, search), while `FileAccessProvider.all_tools_auto_approval_rule` approves every file-access tool including save and delete. The tool names are also exposed as class constants (`SAVE_FILE_TOOL_NAME`, `READ_FILE_TOOL_NAME`, `DELETE_FILE_TOOL_NAME`, `LIST_FILES_TOOL_NAME`, `LIST_SUBDIRECTORIES_TOOL_NAME`, `SEARCH_FILES_TOOL_NAME`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents. ### Tool Approval Harness (`_harness/_tool_approval.py`) diff --git a/python/packages/core/agent_framework/_harness/_file_access.py b/python/packages/core/agent_framework/_harness/_file_access.py index 98024b2cdf..04e93f4aca 100644 --- a/python/packages/core/agent_framework/_harness/_file_access.py +++ b/python/packages/core/agent_framework/_harness/_file_access.py @@ -35,7 +35,8 @@ from .._feature_stage import ExperimentalFeature, experimental from .._serialization import SerializationMixin from .._sessions import AgentSession, ContextProvider, SessionContext -from .._tools import ApprovalMode, tool +from .._tools import tool +from .._types import Content logger = logging.getLogger(__name__) @@ -1004,15 +1005,64 @@ class FileAccessProvider(ContextProvider): contents are visible across sessions and agents. The store is passed in by the caller and should already be scoped to the desired folder or storage location. + + All six tools always require approval: each is registered with + ``approval_mode="always_require"`` so the host must approve every file + operation the model proposes. To run unattended, supply one of the static + auto-approval rules to :class:`~agent_framework.ToolApprovalMiddleware` via + its ``auto_approval_rules``: + + - :meth:`read_only_tools_auto_approval_rule` — auto-approves only the + read-only tools (read, list files, list subdirectories, search), while + still prompting for the tools that modify the store (save and delete). + - :meth:`all_tools_auto_approval_rule` — auto-approves every file-access + tool, including save and delete. + + For example, to auto-approve only the read-only tools:: + + create_harness_agent( + chat_client, + auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule], + ) """ + #: Name of the tool that saves a file. + SAVE_FILE_TOOL_NAME = "file_access_save_file" + #: Name of the tool that reads a file. + READ_FILE_TOOL_NAME = "file_access_read_file" + #: Name of the tool that deletes a file. + DELETE_FILE_TOOL_NAME = "file_access_delete_file" + #: Name of the tool that lists the files in a directory. + LIST_FILES_TOOL_NAME = "file_access_list_files" + #: Name of the tool that lists the subdirectories of a directory. + LIST_SUBDIRECTORIES_TOOL_NAME = "file_access_list_subdirectories" + #: Name of the tool that searches file contents. + SEARCH_FILES_TOOL_NAME = "file_access_search_files" + + #: Names of the tools that only read from (never modify) the file store. + _READ_ONLY_TOOL_NAMES: frozenset[str] = frozenset({ + READ_FILE_TOOL_NAME, + LIST_FILES_TOOL_NAME, + LIST_SUBDIRECTORIES_TOOL_NAME, + SEARCH_FILES_TOOL_NAME, + }) + + #: Names of all tools exposed by this provider. + _ALL_TOOL_NAMES: frozenset[str] = frozenset({ + SAVE_FILE_TOOL_NAME, + READ_FILE_TOOL_NAME, + DELETE_FILE_TOOL_NAME, + LIST_FILES_TOOL_NAME, + LIST_SUBDIRECTORIES_TOOL_NAME, + SEARCH_FILES_TOOL_NAME, + }) + def __init__( self, store: AgentFileStore, *, source_id: str = DEFAULT_FILE_ACCESS_SOURCE_ID, instructions: str | None = None, - require_delete_approval: bool = True, ) -> None: """Initialize the file access provider. @@ -1025,17 +1075,50 @@ def __init__( source_id: Unique source ID for the provider. instructions: Optional instruction override. When ``None`` the default file-access instructions are used. - require_delete_approval: When ``True`` (the default) the - ``file_access_delete_file`` tool is registered with - ``approval_mode="always_require"`` so the host must approve every - delete the model proposes. Set to ``False`` to opt out and allow - the agent to delete files autonomously (matching the .NET - ``FileAccessProvider``, which has no approval mechanism). """ super().__init__(source_id) self.store = store self.instructions = instructions or DEFAULT_FILE_ACCESS_INSTRUCTIONS - self.require_delete_approval = require_delete_approval + + @staticmethod + def read_only_tools_auto_approval_rule(function_call: Content) -> bool: + """Auto-approval rule that approves only the read-only file-access tools. + + The tools exposed by :class:`FileAccessProvider` always require approval. + Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via + ``auto_approval_rules``) to automatically approve the tools that read + from the store (``file_access_read_file``, ``file_access_list_files``, + ``file_access_list_subdirectories``, and ``file_access_search_files``), + while still prompting for the tools that modify it + (``file_access_save_file`` and ``file_access_delete_file``). + + Args: + function_call: The pending ``function_call`` content. + + Returns: + ``True`` for read-only file-access tools, ``False`` otherwise so that + subsequent rules continue to be evaluated. + """ + return function_call.name in FileAccessProvider._READ_ONLY_TOOL_NAMES + + @staticmethod + def all_tools_auto_approval_rule(function_call: Content) -> bool: + """Auto-approval rule that approves every file-access tool. + + The tools exposed by :class:`FileAccessProvider` always require approval. + Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via + ``auto_approval_rules``) to automatically approve every file-access tool, + including the tools that modify the store (``file_access_save_file`` and + ``file_access_delete_file``). + + Args: + function_call: The pending ``function_call`` content. + + Returns: + ``True`` for any file-access tool, ``False`` otherwise so that + subsequent rules continue to be evaluated. + """ + return function_call.name in FileAccessProvider._ALL_TOOL_NAMES async def before_run( self, @@ -1048,7 +1131,7 @@ async def before_run( """Inject file-access tools and instructions before the model runs.""" del agent, session, state - @tool(name="file_access_save_file", approval_mode="never_require") + @tool(name=FileAccessProvider.SAVE_FILE_TOOL_NAME, approval_mode="always_require") async def file_access_save_file(file_name: str, content: str, overwrite: bool = False) -> str: """Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.""" # noqa: E501 try: @@ -1062,7 +1145,7 @@ async def file_access_save_file(file_name: str, content: str, overwrite: bool = return f"Could not save file '{file_name}': {exc.strerror or exc}" return f"File '{file_name}' saved." - @tool(name="file_access_read_file", approval_mode="never_require") + @tool(name=FileAccessProvider.READ_FILE_TOOL_NAME, approval_mode="always_require") async def file_access_read_file(file_name: str) -> str: """Read the content of a file by name. Returns the file content or a message indicating the file could not be read.""" # noqa: E501 try: @@ -1074,9 +1157,7 @@ async def file_access_read_file(file_name: str) -> str: return f"Could not read file '{file_name}': {exc.strerror or exc}" return content if content is not None else f"File '{file_name}' not found." - delete_approval_mode: ApprovalMode = "always_require" if self.require_delete_approval else "never_require" - - @tool(name="file_access_delete_file", approval_mode=delete_approval_mode) + @tool(name=FileAccessProvider.DELETE_FILE_TOOL_NAME, approval_mode="always_require") async def file_access_delete_file(file_name: str) -> str: """Delete a file by name.""" try: @@ -1088,7 +1169,7 @@ async def file_access_delete_file(file_name: str) -> str: return f"Could not delete file '{file_name}': {exc.strerror or exc}" return f"File '{file_name}' deleted." if deleted else f"File '{file_name}' not found." - @tool(name="file_access_list_files", approval_mode="never_require") + @tool(name=FileAccessProvider.LIST_FILES_TOOL_NAME, approval_mode="always_require") async def file_access_list_files(directory: str | None = None) -> list[str] | str: """List the direct child file names of a directory. Omit ``directory`` (or pass an empty string) to list the root. To enumerate files in a subdirectory, pass its relative path, for example ``"reports"`` or ``"reports/2024"``.""" # noqa: E501 target = directory if directory and directory.strip() else "" @@ -1099,7 +1180,7 @@ async def file_access_list_files(directory: str | None = None) -> list[str] | st except OSError as exc: return f"Could not list directory '{directory or ''}': {exc.strerror or exc}" - @tool(name="file_access_list_subdirectories", approval_mode="never_require") + @tool(name=FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME, approval_mode="always_require") async def file_access_list_subdirectories(directory: str | None = None) -> list[str] | str: """List the direct child subdirectory names of a directory. @@ -1116,7 +1197,7 @@ async def file_access_list_subdirectories(directory: str | None = None) -> list[ except OSError as exc: return f"Could not list directory '{directory or ''}': {exc.strerror or exc}" - @tool(name="file_access_search_files", approval_mode="never_require") + @tool(name=FileAccessProvider.SEARCH_FILES_TOOL_NAME, approval_mode="always_require") async def file_access_search_files( regex_pattern: str, file_pattern: str | None = None, diff --git a/python/packages/core/tests/core/test_harness_file_access.py b/python/packages/core/tests/core/test_harness_file_access.py index a873d7c93d..b738fdbcf2 100644 --- a/python/packages/core/tests/core/test_harness_file_access.py +++ b/python/packages/core/tests/core/test_harness_file_access.py @@ -13,6 +13,7 @@ Agent, AgentFileStore, AgentSession, + Content, ExperimentalFeature, FileAccessProvider, FileSearchMatch, @@ -468,10 +469,10 @@ async def test_file_access_provider_registers_tools_and_instructions( assert any(DEFAULT_FILE_ACCESS_INSTRUCTIONS in chunk for chunk in (instructions or [])) -async def test_file_access_provider_delete_approval_defaults_to_always_require( +async def test_file_access_provider_all_tools_require_approval( chat_client_base: SupportsChatGetResponse, ) -> None: - """By default ``file_access_delete_file`` should require host approval.""" + """Every file-access tool should require host approval.""" session = AgentSession(session_id="session-1") provider = FileAccessProvider(store=InMemoryAgentFileStore()) agent = Agent(client=chat_client_base, context_providers=[provider]) @@ -483,34 +484,53 @@ async def test_file_access_provider_delete_approval_defaults_to_always_require( tools = options["tools"] assert isinstance(tools, list) - delete_file = _tool_by_name(tools, "file_access_delete_file") - assert delete_file.approval_mode == "always_require" - # The non-destructive tools should remain autonomous. for name in ( - "file_access_save_file", - "file_access_read_file", - "file_access_list_files", - "file_access_list_subdirectories", - "file_access_search_files", + FileAccessProvider.SAVE_FILE_TOOL_NAME, + FileAccessProvider.READ_FILE_TOOL_NAME, + FileAccessProvider.DELETE_FILE_TOOL_NAME, + FileAccessProvider.LIST_FILES_TOOL_NAME, + FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME, + FileAccessProvider.SEARCH_FILES_TOOL_NAME, ): - assert _tool_by_name(tools, name).approval_mode == "never_require" + assert _tool_by_name(tools, name).approval_mode == "always_require" -async def test_file_access_provider_delete_approval_opt_out( - chat_client_base: SupportsChatGetResponse, -) -> None: - """``require_delete_approval=False`` should drop delete to ``never_require``.""" - session = AgentSession(session_id="session-1") - provider = FileAccessProvider(store=InMemoryAgentFileStore(), require_delete_approval=False) - agent = Agent(client=chat_client_base, context_providers=[provider]) +def test_read_only_tools_auto_approval_rule() -> None: + """The read-only rule approves only the non-mutating tools.""" + approved = { + FileAccessProvider.READ_FILE_TOOL_NAME, + FileAccessProvider.LIST_FILES_TOOL_NAME, + FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME, + FileAccessProvider.SEARCH_FILES_TOOL_NAME, + } + rejected = { + FileAccessProvider.SAVE_FILE_TOOL_NAME, + FileAccessProvider.DELETE_FILE_TOOL_NAME, + "some_other_tool", + } + for name in approved: + call = Content("function_call", call_id="c1", name=name, arguments="{}") + assert FileAccessProvider.read_only_tools_auto_approval_rule(call) is True + for name in rejected: + call = Content("function_call", call_id="c1", name=name, arguments="{}") + assert FileAccessProvider.read_only_tools_auto_approval_rule(call) is False - _, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage] - session=session, - input_messages=[Message(role="user", contents=["work with files"])], - ) - delete_file = _tool_by_name(options["tools"], "file_access_delete_file") # type: ignore[arg-type] - assert delete_file.approval_mode == "never_require" +def test_all_tools_auto_approval_rule() -> None: + """The all-tools rule approves every file-access tool but nothing else.""" + for name in ( + FileAccessProvider.SAVE_FILE_TOOL_NAME, + FileAccessProvider.READ_FILE_TOOL_NAME, + FileAccessProvider.DELETE_FILE_TOOL_NAME, + FileAccessProvider.LIST_FILES_TOOL_NAME, + FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME, + FileAccessProvider.SEARCH_FILES_TOOL_NAME, + ): + call = Content("function_call", call_id="c1", name=name, arguments="{}") + assert FileAccessProvider.all_tools_auto_approval_rule(call) is True + + unrelated = Content("function_call", call_id="c1", name="some_other_tool", arguments="{}") + assert FileAccessProvider.all_tools_auto_approval_rule(unrelated) is False async def test_file_access_provider_tools_round_trip_files( diff --git a/python/samples/02-agents/harness/harness_data_processing.py b/python/samples/02-agents/harness/harness_data_processing.py new file mode 100644 index 0000000000..6699c69785 --- /dev/null +++ b/python/samples/02-agents/harness/harness_data_processing.py @@ -0,0 +1,146 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework", +# "textual>=6.2.1", +# "rich>=13.7.1", +# "azure-identity", +# "python-dotenv", +# ] +# /// +# Run with any PEP 723 compatible runner, e.g.: +# uv run samples/02-agents/harness/harness_data_processing.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Harness Data Processing Assistant with Console UI and tool approvals. + +Demonstrates ``create_harness_agent`` configured with a ``FileAccessProvider`` +to give an agent access to a folder of CSV data files. The agent can read, +analyze, and extract information from the data, then write results back as new +files via the ``file_access_*`` tools. + +This sample also demonstrates **tool approval**. The ``FileAccessProvider`` +registers all of its tools with ``approval_mode="always_require"``, so every +file operation would normally prompt the host for approval. To keep read-only +exploration frictionless while still guarding mutations, the agent is given the +:meth:`FileAccessProvider.read_only_tools_auto_approval_rule` auto-approval +rule. With this rule: + +- Read-only tools (read, list files, list subdirectories, search) are + auto-approved and run without prompting. +- Write tools (save and delete) still require explicit approval, so you are + asked before the agent modifies the file store. + +The sample includes a pre-populated ``working/`` folder with sales transaction +data. The ``FileAccessProvider`` is pointed at that folder (resolved relative to +this script) so it works regardless of the current working directory. Ask the +agent to analyze the data, produce summaries, or create new output files. For +example:: + + Please process the sales.csv file by first filtering it to only North region + sales, and then calculating the sum of sales by person. I'd like to write the + results of the processing to north_region_totals.csv + +When the agent reads ``sales.csv`` it proceeds automatically, but when it tries +to save ``north_region_totals.csv`` you are prompted to approve the write. + +Unused harness features (todos, plan/execute mode, web search) are disabled to +keep this a simple, conversational data-interaction sample. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint URL + FOUNDRY_MODEL — Model deployment name + +Authentication: + Run ``az login`` before running this sample. +""" + +import asyncio +from pathlib import Path + +from agent_framework import FileAccessProvider, FileSystemAgentFileStore, create_harness_agent +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from console import build_default_observers, run_agent_async +from dotenv import load_dotenv + +DATA_ANALYST_INSTRUCTIONS = """\ +You are a data analyst assistant. You have access to a folder of data files via the file_access_* tools. + +## Getting started +- Start by listing available files with file_access_list_files to see what data is available. +- Read the files to understand their structure and contents. + +## Working with data +- When asked to analyze data, read the relevant files first, then perform the analysis. +- Show your analysis clearly with tables, summaries, and key insights. +- When calculations are needed, work through them step by step and show your reasoning. + +## Writing output +- When asked to produce output files (e.g., reports, summaries, filtered data), use file_access_save_file to write them. +- Use appropriate file formats: CSV for tabular data, Markdown for reports. +- Confirm what you wrote and where. + +## Important +- Never modify or delete the original input data files unless explicitly asked to do so. +- If asked about data you haven't read yet, read it first before answering. +- Always explain your reasoning and thought process as you work through tasks. +- Always explain what you learned and what you are going to do next between tool calls, so the user can + follow along with your thought process. +""" + +MAX_CONTEXT_WINDOW_TOKENS = 1_050_000 +MAX_OUTPUT_TOKENS = 128_000 + + +async def main() -> None: + load_dotenv() + + # Resolve the working/ folder bundled alongside this script. The agent reads + # the seed data from here and writes any output files back into it. + working_dir = Path(__file__).parent / "working" + + # Create the chat client. + # For authentication, run `az login` in terminal or replace AzureCliCredential + # with your preferred authentication option. + client = FoundryChatClient(credential=AzureCliCredential()) + + # Wire a FileAccessProvider pointed at the sample's working/ folder so it + # works regardless of the current working directory. All of its tools + # require approval; the read-only auto-approval rule below lets read/list/ + # search run automatically while save/delete still prompt for approval. + file_access_provider = FileAccessProvider(FileSystemAgentFileStore(working_dir)) + + # Create a harness agent with data-analyst instructions. Unused features are + # disabled. The read_only_tools_auto_approval_rule auto-approves the + # FileAccessProvider's read-only tools, so only write operations prompt. + agent = create_harness_agent( + client=client, + max_context_window_tokens=MAX_CONTEXT_WINDOW_TOKENS, + max_output_tokens=MAX_OUTPUT_TOKENS, + name="DataAnalyst", + description="A data analyst assistant that reads, analyzes, and processes data files.", + agent_instructions=DATA_ANALYST_INSTRUCTIONS, + context_providers=[file_access_provider], + auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule], + disable_todo=True, + disable_mode=True, + disable_web_search=True, + ) + + # Run the harness console. This sample has no plan/execute mode, so it uses + # the default observers (no planning observer) and no initial mode. + await run_agent_async( + agent, + session=agent.create_session(), + observers=build_default_observers(), + title="📊 Data Analyst", + placeholder="Ask me to analyze the data files, produce summaries, or create output files...", + max_context_window_tokens=MAX_CONTEXT_WINDOW_TOKENS, + max_output_tokens=MAX_OUTPUT_TOKENS, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/harness/working/sales.csv b/python/samples/02-agents/harness/working/sales.csv new file mode 100644 index 0000000000..50a2369942 --- /dev/null +++ b/python/samples/02-agents/harness/working/sales.csv @@ -0,0 +1,50 @@ +date,product,category,quantity,unit_price,region,salesperson +2025-01-03,Laptop Pro 15,Electronics,2,1299.99,North,Alice +2025-01-05,Ergonomic Chair,Furniture,5,349.50,South,Bob +2025-01-07,Wireless Mouse,Electronics,12,24.99,North,Alice +2025-01-08,Standing Desk,Furniture,1,599.00,West,Carol +2025-01-10,USB-C Hub,Electronics,8,45.99,North,David +2025-01-12,Monitor 27in,Electronics,3,429.00,South,Bob +2025-01-14,Desk Lamp,Furniture,6,79.95,West,Carol +2025-01-15,Keyboard Mech,Electronics,4,149.99,North,Alice +2025-01-17,Filing Cabinet,Furniture,2,189.00,South,David +2025-01-20,Webcam HD,Electronics,10,89.99,West,Bob +2025-01-22,Laptop Pro 15,Electronics,1,1299.99,South,Carol +2025-01-24,Ergonomic Chair,Furniture,3,349.50,North,Alice +2025-01-25,Notebook Pack,Stationery,20,12.99,South,David +2025-01-27,Wireless Mouse,Electronics,15,24.99,West,Carol +2025-01-28,Whiteboard,Stationery,4,129.00,North,Bob +2025-01-30,Standing Desk,Furniture,2,599.00,South,Alice +2025-02-02,USB-C Hub,Electronics,6,45.99,West,David +2025-02-04,Monitor 27in,Electronics,2,429.00,North,Carol +2025-02-05,Desk Lamp,Furniture,8,79.95,South,Bob +2025-02-07,Keyboard Mech,Electronics,5,149.99,West,Alice +2025-02-09,Filing Cabinet,Furniture,1,189.00,North,David +2025-02-11,Webcam HD,Electronics,7,89.99,South,Carol +2025-02-13,Laptop Pro 15,Electronics,3,1299.99,West,Bob +2025-02-15,Notebook Pack,Stationery,30,12.99,North,Alice +2025-02-17,Ergonomic Chair,Furniture,4,349.50,South,David +2025-02-19,Wireless Mouse,Electronics,20,24.99,North,Carol +2025-02-20,Whiteboard,Stationery,2,129.00,West,Bob +2025-02-22,Standing Desk,Furniture,1,599.00,North,Alice +2025-02-24,USB-C Hub,Electronics,10,45.99,South,David +2025-02-26,Monitor 27in,Electronics,4,429.00,West,Carol +2025-02-28,Desk Lamp,Furniture,3,79.95,North,Bob +2025-03-02,Keyboard Mech,Electronics,6,149.99,South,Alice +2025-03-04,Filing Cabinet,Furniture,3,189.00,West,David +2025-03-06,Webcam HD,Electronics,9,89.99,North,Carol +2025-03-08,Laptop Pro 15,Electronics,2,1299.99,South,Bob +2025-03-10,Notebook Pack,Stationery,25,12.99,West,Alice +2025-03-12,Ergonomic Chair,Furniture,6,349.50,North,David +2025-03-14,Wireless Mouse,Electronics,18,24.99,South,Carol +2025-03-15,Whiteboard,Stationery,5,129.00,North,Bob +2025-03-17,Standing Desk,Furniture,3,599.00,West,Alice +2025-03-19,USB-C Hub,Electronics,7,45.99,North,David +2025-03-21,Monitor 27in,Electronics,5,429.00,South,Carol +2025-03-23,Desk Lamp,Furniture,4,79.95,West,Bob +2025-03-25,Keyboard Mech,Electronics,3,149.99,North,Alice +2025-03-27,Filing Cabinet,Furniture,2,189.00,South,David +2025-03-28,Webcam HD,Electronics,11,89.99,West,Carol +2025-03-29,Laptop Pro 15,Electronics,1,1299.99,North,Bob +2025-03-30,Notebook Pack,Stationery,15,12.99,South,Alice +2025-03-31,Ergonomic Chair,Furniture,2,349.50,West,David From daf32779d64be7bc5c3701075de0f6b99cec5592 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:49:25 +0000 Subject: [PATCH 2/4] Scope file-access auto-approval rules to local tools; fix base-Agent sample Address PR #6599 review feedback: - read_only/all_tools auto-approval rules now reject any call carrying a server_label so they stay scoped to FileAccessProvider's local tools and never auto-approve a same-named hosted tool. - Expand the FileAccessProvider docstring to explain the runtime effect of approval_mode="always_require" and point to ToolApprovalMiddleware / create_harness_agent. - Fix the base-Agent file_access_data_processing sample, which would otherwise stop executing file tools under the new always_require defaults, by adding ToolApprovalMiddleware with all_tools_auto_approval_rule. - Add tests covering hosted (server_label) calls and update docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/AGENTS.md | 2 +- .../agent_framework/_harness/_file_access.py | 46 +++++++++++++++++-- .../tests/core/test_harness_file_access.py | 19 ++++++++ .../file_access_data_processing/README.md | 9 ++++ .../data_processing.py | 16 ++++++- 5 files changed, 84 insertions(+), 8 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index b553fd9253..e9c0714bcb 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -98,7 +98,7 @@ agent_framework/ - **`InMemoryAgentFileStore`** - Dict-backed store suitable for tests and lightweight scenarios. - **`FileSystemAgentFileStore`** - Disk-backed store rooted under a configurable directory. Enforces relative-path normalization, root containment, and rejects symlink/reparse-point segments to prevent escape. - **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search_files`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers. -- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). All six tools are registered with `approval_mode="always_require"`, so every file operation needs host approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `FileAccessProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (read, list files, list subdirectories, search), while `FileAccessProvider.all_tools_auto_approval_rule` approves every file-access tool including save and delete. The tool names are also exposed as class constants (`SAVE_FILE_TOOL_NAME`, `READ_FILE_TOOL_NAME`, `DELETE_FILE_TOOL_NAME`, `LIST_FILES_TOOL_NAME`, `LIST_SUBDIRECTORIES_TOOL_NAME`, `SEARCH_FILES_TOOL_NAME`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents. +- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). All six tools are registered with `approval_mode="always_require"`, so every file operation needs host approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `FileAccessProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (read, list files, list subdirectories, search), while `FileAccessProvider.all_tools_auto_approval_rule` approves every file-access tool including save and delete. 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 (`SAVE_FILE_TOOL_NAME`, `READ_FILE_TOOL_NAME`, `DELETE_FILE_TOOL_NAME`, `LIST_FILES_TOOL_NAME`, `LIST_SUBDIRECTORIES_TOOL_NAME`, `SEARCH_FILES_TOOL_NAME`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents. ### Tool Approval Harness (`_harness/_tool_approval.py`) diff --git a/python/packages/core/agent_framework/_harness/_file_access.py b/python/packages/core/agent_framework/_harness/_file_access.py index 04e93f4aca..18288121c4 100644 --- a/python/packages/core/agent_framework/_harness/_file_access.py +++ b/python/packages/core/agent_framework/_harness/_file_access.py @@ -1008,9 +1008,17 @@ class FileAccessProvider(ContextProvider): All six tools always require approval: each is registered with ``approval_mode="always_require"`` so the host must approve every file - operation the model proposes. To run unattended, supply one of the static - auto-approval rules to :class:`~agent_framework.ToolApprovalMiddleware` via - its ``auto_approval_rules``: + operation the model proposes. In the auto-invocation flow this means the + model's calls to these tools are converted into + ``function_approval_request`` items and the tool does **not** execute until + the host supplies a matching ``function_approval_response``. Consumers that + use the base agent directly must install + :class:`~agent_framework.ToolApprovalMiddleware` (or use + :func:`~agent_framework.create_harness_agent`, which wires it in by default) + to drive that handshake; otherwise these tools never run. To run unattended, + supply one of the static auto-approval rules to + :class:`~agent_framework.ToolApprovalMiddleware` via its + ``auto_approval_rules``: - :meth:`read_only_tools_auto_approval_rule` — auto-approves only the read-only tools (read, list files, list subdirectories, search), while @@ -1080,6 +1088,20 @@ def __init__( self.store = store self.instructions = instructions or DEFAULT_FILE_ACCESS_INSTRUCTIONS + @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 + file-access 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 file-access tools. @@ -1092,6 +1114,10 @@ def read_only_tools_auto_approval_rule(function_call: Content) -> bool: while still prompting for the tools that modify it (``file_access_save_file`` and ``file_access_delete_file``). + Hosted-tool calls (those carrying a ``server_label``) are never + auto-approved, even when their name matches a file-access tool, so the + rule stays scoped to this provider's local tools. + Args: function_call: The pending ``function_call`` content. @@ -1099,7 +1125,10 @@ def read_only_tools_auto_approval_rule(function_call: Content) -> bool: ``True`` for read-only file-access tools, ``False`` otherwise so that subsequent rules continue to be evaluated. """ - return function_call.name in FileAccessProvider._READ_ONLY_TOOL_NAMES + return ( + FileAccessProvider._is_local_tool_call(function_call) + and function_call.name in FileAccessProvider._READ_ONLY_TOOL_NAMES + ) @staticmethod def all_tools_auto_approval_rule(function_call: Content) -> bool: @@ -1111,6 +1140,10 @@ def all_tools_auto_approval_rule(function_call: Content) -> bool: including the tools that modify the store (``file_access_save_file`` and ``file_access_delete_file``). + Hosted-tool calls (those carrying a ``server_label``) are never + auto-approved, even when their name matches a file-access tool, so the + rule stays scoped to this provider's local tools. + Args: function_call: The pending ``function_call`` content. @@ -1118,7 +1151,10 @@ def all_tools_auto_approval_rule(function_call: Content) -> bool: ``True`` for any file-access tool, ``False`` otherwise so that subsequent rules continue to be evaluated. """ - return function_call.name in FileAccessProvider._ALL_TOOL_NAMES + return ( + FileAccessProvider._is_local_tool_call(function_call) + and function_call.name in FileAccessProvider._ALL_TOOL_NAMES + ) async def before_run( self, diff --git a/python/packages/core/tests/core/test_harness_file_access.py b/python/packages/core/tests/core/test_harness_file_access.py index b738fdbcf2..db79d09348 100644 --- a/python/packages/core/tests/core/test_harness_file_access.py +++ b/python/packages/core/tests/core/test_harness_file_access.py @@ -514,6 +514,16 @@ def test_read_only_tools_auto_approval_rule() -> None: for name in rejected: call = Content("function_call", call_id="c1", name=name, arguments="{}") assert FileAccessProvider.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 FileAccessProvider.read_only_tools_auto_approval_rule(hosted) is False def test_all_tools_auto_approval_rule() -> None: @@ -528,6 +538,15 @@ def test_all_tools_auto_approval_rule() -> None: ): call = Content("function_call", call_id="c1", name=name, arguments="{}") assert FileAccessProvider.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 FileAccessProvider.all_tools_auto_approval_rule(hosted) is False unrelated = Content("function_call", call_id="c1", name="some_other_tool", arguments="{}") assert FileAccessProvider.all_tools_auto_approval_rule(unrelated) is False diff --git a/python/samples/02-agents/context_providers/file_access_data_processing/README.md b/python/samples/02-agents/context_providers/file_access_data_processing/README.md index 9984b1f13b..501399f40d 100644 --- a/python/samples/02-agents/context_providers/file_access_data_processing/README.md +++ b/python/samples/02-agents/context_providers/file_access_data_processing/README.md @@ -19,6 +19,15 @@ that exercises every tool the provider exposes: After the run, the sample prints the final contents of `working/` so the written file is easy to spot. +## Tool approval + +The `file_access_*` tools all require approval (`approval_mode="always_require"`), +so a base `Agent` installs `ToolApprovalMiddleware` to drive the approval +handshake — without it the file tools would never execute. Because this sample +runs a non-interactive scripted conversation, it auto-approves every file-access +tool via `FileAccessProvider.all_tools_auto_approval_rule`. (`create_harness_agent` +wires `ToolApprovalMiddleware` automatically; a base `Agent` does not.) + ## Prerequisites | Variable | Description | diff --git a/python/samples/02-agents/context_providers/file_access_data_processing/data_processing.py b/python/samples/02-agents/context_providers/file_access_data_processing/data_processing.py index aac04f6ea1..ff163fcef4 100644 --- a/python/samples/02-agents/context_providers/file_access_data_processing/data_processing.py +++ b/python/samples/02-agents/context_providers/file_access_data_processing/data_processing.py @@ -7,6 +7,12 @@ data, perform analysis, and write summary output back to the same folder via the ``file_access_*`` tools. +The file-access tools all require approval (``approval_mode="always_require"``), +so a base ``Agent`` installs :class:`ToolApprovalMiddleware` to drive the +approval handshake. Because this sample is non-interactive, it auto-approves +every file-access tool via +:meth:`FileAccessProvider.all_tools_auto_approval_rule`. + The sibling ``working/`` folder contains ``sales.csv`` — ~50 rows of sales transactions (date, product, category, quantity, unit_price, region, salesperson). The agent is asked, in a single session, to: list available @@ -22,7 +28,7 @@ import os from pathlib import Path -from agent_framework import Agent, FileAccessProvider, FileSystemAgentFileStore +from agent_framework import Agent, FileAccessProvider, FileSystemAgentFileStore, ToolApprovalMiddleware from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -92,13 +98,19 @@ async def main() -> None: # agent for the duration of each run. file_access = FileAccessProvider(store=FileSystemAgentFileStore(working_dir)) - # 4. Create the agent and attach the provider. + # 4. Create the agent and attach the provider. The file-access tools all + # require approval (approval_mode="always_require"), so a base Agent must + # install ToolApprovalMiddleware for them to run at all. This sample is + # non-interactive, so it auto-approves every file-access tool via + # FileAccessProvider.all_tools_auto_approval_rule. (create_harness_agent + # wires ToolApprovalMiddleware automatically; a base Agent does not.) async with Agent( client=client, name="DataAnalyst", description="A data analyst assistant that reads, analyzes, and processes data files.", instructions=INSTRUCTIONS, context_providers=[file_access], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[FileAccessProvider.all_tools_auto_approval_rule])], ) as agent: # 5. Run all prompts inside one session so the conversation remains # coherent across turns. From e79ac380735bce1469baec2bfa384ade1e655780 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:00:22 +0000 Subject: [PATCH 3/4] Clean up comments --- .../file_access_data_processing/README.md | 9 --------- .../file_access_data_processing/data_processing.py | 10 +++++----- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/python/samples/02-agents/context_providers/file_access_data_processing/README.md b/python/samples/02-agents/context_providers/file_access_data_processing/README.md index 501399f40d..9984b1f13b 100644 --- a/python/samples/02-agents/context_providers/file_access_data_processing/README.md +++ b/python/samples/02-agents/context_providers/file_access_data_processing/README.md @@ -19,15 +19,6 @@ that exercises every tool the provider exposes: After the run, the sample prints the final contents of `working/` so the written file is easy to spot. -## Tool approval - -The `file_access_*` tools all require approval (`approval_mode="always_require"`), -so a base `Agent` installs `ToolApprovalMiddleware` to drive the approval -handshake — without it the file tools would never execute. Because this sample -runs a non-interactive scripted conversation, it auto-approves every file-access -tool via `FileAccessProvider.all_tools_auto_approval_rule`. (`create_harness_agent` -wires `ToolApprovalMiddleware` automatically; a base `Agent` does not.) - ## Prerequisites | Variable | Description | diff --git a/python/samples/02-agents/context_providers/file_access_data_processing/data_processing.py b/python/samples/02-agents/context_providers/file_access_data_processing/data_processing.py index ff163fcef4..49624d8eda 100644 --- a/python/samples/02-agents/context_providers/file_access_data_processing/data_processing.py +++ b/python/samples/02-agents/context_providers/file_access_data_processing/data_processing.py @@ -99,11 +99,11 @@ async def main() -> None: file_access = FileAccessProvider(store=FileSystemAgentFileStore(working_dir)) # 4. Create the agent and attach the provider. The file-access tools all - # require approval (approval_mode="always_require"), so a base Agent must - # install ToolApprovalMiddleware for them to run at all. This sample is - # non-interactive, so it auto-approves every file-access tool via - # FileAccessProvider.all_tools_auto_approval_rule. (create_harness_agent - # wires ToolApprovalMiddleware automatically; a base Agent does not.) + # require approval (approval_mode="always_require"). Developers can + # present these to the user for approval, or like in this case, auto-approve + # them via FileAccessProvider.all_tools_auto_approval_rule. Note that + # to use tool approval rules, the agent must have ToolApprovalMiddleware + # in its middleware stack. async with Agent( client=client, name="DataAnalyst", From 437fff74c0492d88159d222bf7b6bcd65dc8ef17 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:34:19 +0000 Subject: [PATCH 4/4] Update sample after merge --- python/samples/02-agents/harness/README.md | 25 +++++++++++++++++-- .../harness/harness_data_processing.py | 8 +----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/python/samples/02-agents/harness/README.md b/python/samples/02-agents/harness/README.md index 13fa2aa7f5..49c40bd74c 100644 --- a/python/samples/02-agents/harness/README.md +++ b/python/samples/02-agents/harness/README.md @@ -28,6 +28,7 @@ Each feature can be disabled or customized via keyword arguments. | File | Description | |------|-------------| | `harness_research.py` | Interactive research assistant with web search and planning workflow | +| `harness_data_processing.py` | Data-processing assistant over a folder of CSV files, demonstrating file-access tools and tool approval | ## Running @@ -39,10 +40,30 @@ export FOUNDRY_MODEL="your-model-deployment-name" # Authenticate with Azure (required for AzureCliCredential) az login -# Run the research sample -python samples/02-agents/harness/harness_research.py +# Run a sample against the released agent-framework (PEP 723 isolated env) +uv run samples/02-agents/harness/harness_research.py ``` +### Running against the local repo + +To run a sample against your **local** `agent-framework` checkout (so it picks +up uncommitted changes), use the workspace environment instead of the isolated +PEP 723 env. From the `python/` directory, run the script with `uv run python` +and add the `textual` UI dependency the harness console needs: + +```bash +uv run --with textual python samples/02-agents/harness/harness_research.py +uv run --with textual python samples/02-agents/harness/harness_data_processing.py +``` + +The workspace environment already provides the editable `agent-framework` +packages plus the samples' other dependencies (`rich`, `python-dotenv`, +`azure-identity`); only `textual` needs to be supplied with `--with`. + +> Note: invoking `uv run python