diff --git a/sdk/ai/azure-ai-projects/CHANGELOG.md b/sdk/ai/azure-ai-projects/CHANGELOG.md index a75d6f94f28a..95cb35e9f97e 100644 --- a/sdk/ai/azure-ai-projects/CHANGELOG.md +++ b/sdk/ai/azure-ai-projects/CHANGELOG.md @@ -14,6 +14,11 @@ * Placeholder +### Sample updates + +* Added new Hosted Agent sample `sample_agent_user_identity_isolation.py` under `samples/hosted_agents/`, demonstrating per-user response-chain isolation with delegated end-user identities sent in the `x-ms-user-identity` header. +* Updated Hosted Agent toolbox asset `samples/hosted_agents/assets/toolbox-agent/main.py` to use `FoundryToolbox` and `as_skills_provider()` for toolbox MCP skill discovery and wiring, replacing the earlier manual MCP session, auth, and HTTP client setup. +* Updated Hosted Agent asset requirements under `samples/hosted_agents/assets/toolbox-agent/requirements.txt` to use new versions of `agent-framework-foundry` and `agent-framework-foundry-hosting` ## 2.3.0 (2026-07-01) diff --git a/sdk/ai/azure-ai-projects/assets.json b/sdk/ai/azure-ai-projects/assets.json index bd87e3e3ddc3..ea99ed512e5d 100644 --- a/sdk/ai/azure-ai-projects/assets.json +++ b/sdk/ai/azure-ai-projects/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/ai/azure-ai-projects", - "Tag": "python/ai/azure-ai-projects_762155b7e7" + "Tag": "python/ai/azure-ai-projects_82895b86e0" } diff --git a/sdk/ai/azure-ai-projects/cspell.json b/sdk/ai/azure-ai-projects/cspell.json index 236c590be97c..0fa07fb280b2 100644 --- a/sdk/ai/azure-ai-projects/cspell.json +++ b/sdk/ai/azure-ai-projects/cspell.json @@ -17,6 +17,8 @@ "dargilco", "dedup", "deser", + "devtools", + "dotenv", "evals", "FineTuning", "fspath", @@ -35,10 +37,13 @@ "reraises", "simpleqna", "skoid", + "testutils", "Tadmaq", "Udbk", "UPIA", + "unsanitized", "Vnext", + "westus", "xhigh", "likert" ], diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic.py index 575a7ca885f3..81de414621c6 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic.py @@ -64,7 +64,7 @@ with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): # ------------------------------------------------------------------ diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic_async.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic_async.py index 6a636a03c5a0..90137d28696c 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_basic_async.py @@ -63,7 +63,7 @@ async def main() -> None: async with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): # ------------------------------------------------------------------ diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_cancel.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_cancel.py index 9dd011a14394..ade59c30249f 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_cancel.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_cancel.py @@ -56,7 +56,7 @@ with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): # ------------------------------------------------------------------ diff --git a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_list_get_delete.py b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_list_get_delete.py index f2d87a84b668..c49761640661 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_list_get_delete.py +++ b/sdk/ai/azure-ai-projects/samples/agents/optimization/sample_optimization_job_list_get_delete.py @@ -39,7 +39,7 @@ with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): # ------------------------------------------------------------------ diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_toolbox_skill.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_toolbox_skill.py index 25c4a2a96d1d..847a9d206333 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_toolbox_skill.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_toolbox_skill.py @@ -63,7 +63,7 @@ with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, project_client.get_openai_client() as openai_client, ): diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/basic-agent/main.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/basic-agent/main.py new file mode 100644 index 000000000000..c9cf1ef1c806 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/basic-agent/main.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer # type: ignore[import-untyped] +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv + +load_dotenv() + + +async def main() -> None: + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + deployment = os.environ["FOUNDRY_MODEL_NAME"] + store_responses = os.environ.get("AGENT_STORE_RESPONSES", "false").lower() == "true" + + with DefaultAzureCredential() as credential: + agent = Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=deployment, + credential=credential, + allow_preview=True, + ), + name=os.environ.get("AGENT_NAME", "BASIC_AGENT"), + instructions=os.environ.get( + "AGENT_INSTRUCTIONS", + ), + default_options={"store": store_responses}, + ) + + server = ResponsesHostServer(agent) + await server.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/basic-agent/requirements.txt b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/basic-agent/requirements.txt new file mode 100644 index 000000000000..74e9d565df41 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/basic-agent/requirements.txt @@ -0,0 +1,3 @@ +agent-framework-foundry==1.10.0 +agent-framework-foundry-hosting>=1.0.0a260630 +python-dotenv diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent-prebuilt.zip b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent-prebuilt.zip new file mode 100644 index 000000000000..4280ae5bf8a6 Binary files /dev/null and b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent-prebuilt.zip differ diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent/main.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent/main.py index 3a1829d1fd21..51ba81ad11b6 100644 --- a/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent/main.py +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent/main.py @@ -2,78 +2,53 @@ import asyncio import os -from collections.abc import Callable, Generator -import httpx -from typing import Any, cast -from agent_framework import Agent, SkillsProvider +from agent_framework import Agent from agent_framework.foundry import FoundryChatClient -from agent_framework_foundry_hosting import ResponsesHostServer # type: ignore[import-untyped] -from azure.identity import DefaultAzureCredential, get_bearer_token_provider +from agent_framework_foundry_hosting import FoundryToolbox, ResponsesHostServer +from azure.identity import DefaultAzureCredential from dotenv import load_dotenv -from mcp.client.session import ClientSession -from mcp.client.streamable_http import streamable_http_client - -MCPSkillsSource = cast(Any, __import__("agent_framework", fromlist=["MCPSkillsSource"]).MCPSkillsSource) +# Load environment variables from .env file load_dotenv() -class ToolboxAuth(httpx.Auth): - """Attach a fresh Foundry bearer token to every request.""" - - requires_response_body = True - - def __init__(self, token_provider: Callable[[], str]) -> None: - self._token_provider = token_provider - - def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: - request.headers["Authorization"] = f"Bearer {self._token_provider()}" - yield request - - async def main() -> None: - project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] - deployment = os.environ["FOUNDRY_MODEL_NAME"] - toolbox_mcp_url = os.environ["MCP_SERVER_URL"] - store_responses = os.environ.get("AGENT_STORE_RESPONSES", "false").lower() == "true" - credential = DefaultAzureCredential() - token_provider = get_bearer_token_provider(credential, "https://ai.azure.com/.default") - - async with ( - httpx.AsyncClient( - auth=ToolboxAuth(token_provider), - headers={"Foundry-Features": "Routines=V1Preview"}, - timeout=httpx.Timeout(30.0, read=300.0), - follow_redirects=True, - ) as http_client, - streamable_http_client( - url=toolbox_mcp_url, - http_client=http_client, - ) as (read, write, _), - ClientSession(read, write) as session, - ): - await session.initialize() - - skills_provider = SkillsProvider(MCPSkillsSource(client=session)) - - agent = Agent( - client=FoundryChatClient( - project_endpoint=project_endpoint, - model=deployment, - credential=credential, - ), - name=os.environ.get("AGENT_NAME", "TOOLBOX_AGENT"), - instructions=os.environ.get( - "AGENT_INSTRUCTIONS", - ), - context_providers=[skills_provider], - default_options={"store": store_responses}, - ) - server = ResponsesHostServer(agent) - await server.run_async() + # FoundryToolbox resolves the toolbox endpoint from the environment + # (TOOLBOX_ENDPOINT, or FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME), authenticates + # every request with the credential, and forwards the platform per-request + # call-id. ``load_tools=False`` keeps the toolbox's tools hidden so only its + # Agent Skills (SEP-2640) are surfaced; passing it via ``tools=`` connects the + # MCP session that ``as_skills_provider()`` reads from. + toolbox = FoundryToolbox(url=os.environ["MCP_SERVER_URL"], credential=credential, load_tools=False) + + # as_skills_provider() discovers skills from skill://index.json on the toolbox + # MCP session and exposes them as an agent context provider; SKILL.md bodies are + # fetched on demand via resources/read. + skills_provider = toolbox.as_skills_provider() + + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL_NAME"], + credential=credential, + ) + + agent = Agent( + client=client, + name=os.environ.get("AGENT_NAME", "hosted-toolbox-mcp-skills"), + instructions="You are a helpful assistant.", + tools=toolbox, + context_providers=[skills_provider], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + + server = ResponsesHostServer(agent) + await server.run_async() if __name__ == "__main__": diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent/requirements.txt b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent/requirements.txt index 6cf68528e791..74e9d565df41 100644 --- a/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent/requirements.txt +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/assets/toolbox-agent/requirements.txt @@ -1,10 +1,3 @@ -agent-framework-foundry==1.8.2 -agent-framework-foundry-hosting==1.0.0a260618 -azure-ai-agentserver-core -azure-ai-agentserver-invocations -azure-ai-agentserver-responses -azure-ai-projects -azure-identity -httpx -mcp +agent-framework-foundry==1.10.0 +agent-framework-foundry-hosting>=1.0.0a260630 python-dotenv diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_agent_user_identity_isolation.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_agent_user_identity_isolation.py new file mode 100644 index 000000000000..e46d43a3a8b3 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_agent_user_identity_isolation.py @@ -0,0 +1,193 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + This sample demonstrates per-user Hosted Agent isolation while sending + delegated end-user identities in the `x-ms-user-identity` header. + + It packages the basic hosted-agent source folder as a new Hosted Agent + version, routes the `MyHostedAgent` endpoint to that version, and then + invokes the same Hosted Agent as two different delegated users. The sample + shows that a follow-up request can continue a prior response chain only + when the delegated user identity matches the user who started it: + + * First delegated user: "1 + 1 = ?" then "then + 10" -> 12 + * Second delegated user: attempting "then + 10" against the first user's + response chain is expected to fail with `404 NotFound`, confirming the + response history is isolated per delegated user. + +USAGE: + python sample_agent_user_identity_isolation.py + + Before running the sample: + + pip install "azure-ai-projects>=2.3.0" python-dotenv + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - The Azure AI Project endpoint. + 2) FOUNDRY_MODEL_NAME - The deployment name of the AI model. + 3) AZURE_SUBSCRIPTION_ID - Azure subscription ID where the Azure AI account + and project are deployed. + 4) DELEGATED_USER_IDENTITY - Optional fixed delegated user identity to use + for the first request chain. If omitted, a random UUID is generated. + 5) DELEGATED_USER_IDENTITY_2 - Optional fixed delegated user identity to + use for the second user. If omitted, a random UUID is generated. +""" + +import os +import sys +import time +from pathlib import Path +from uuid import uuid4 + +from dotenv import load_dotenv +from openai import NotFoundError + +from azure.identity import DefaultAzureCredential + +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import ( + AgentEndpointConfig, + CodeConfiguration, + CodeDependencyResolution, + FixedRatioVersionSelectionRule, + HostedAgentDefinition, + ProtocolConfiguration, + ProtocolVersionRecord, + ResponsesProtocolConfiguration, + VersionSelector, +) + +_SAMPLES_DIR = Path(__file__).resolve().parents[1] +if str(_SAMPLES_DIR) not in sys.path: + sys.path.insert(0, str(_SAMPLES_DIR)) + +from rbac_util import ensure_agent_identity_rbac +from util import zip + +load_dotenv() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +model_name = os.environ["FOUNDRY_MODEL_NAME"] +subscription_id = os.environ["AZURE_SUBSCRIPTION_ID"] +agent_name = "MyHostedAgent" +delegated_user_identity = os.environ.get("DELEGATED_USER_IDENTITY", str(uuid4())) +delegated_user_identity_2 = os.environ.get("DELEGATED_USER_IDENTITY_2", str(uuid4())) +hosted_agent_source_dir = Path(__file__).parent / "assets" / "basic-agent" + +with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, +): + created_version = None + original_agent_endpoint = None + + try: + zip_filename = "basic-agent.zip" + zip_path = zip(hosted_agent_source_dir, zip_filename)[2] + + with zip_path.open("rb") as code_stream: + created_version = project_client.agents.create_version_from_code( + agent_name=agent_name, + description="Hosted agent version for delegated user identity isolation.", + definition=HostedAgentDefinition( + cpu="0.5", + memory="1Gi", + code_configuration=CodeConfiguration( + runtime="python_3_14", + entry_point=["python", "main.py"], + dependency_resolution=CodeDependencyResolution.REMOTE_BUILD, + ), + environment_variables={ + "FOUNDRY_PROJECT_ENDPOINT": endpoint, + "FOUNDRY_MODEL_NAME": model_name, + "AGENT_INSTRUCTIONS": ( + "You are a helpful assistant that answers arithmetic questions. " + "Use the prior response context to resolve follow-up math questions like 'then + 10'." + ), + }, + protocol_versions=[ProtocolVersionRecord(protocol="responses", version="2.0.0")], + ), + code=code_stream, + ) + print( + f"Hosted agent version created (id: {created_version.id}, name: {created_version.name}, " + f"version: {created_version.version})" + ) + + for attempt in range(60): + time.sleep(10) + version_details = project_client.agents.get_version( + agent_name=agent_name, agent_version=created_version.version + ) + print(f"Hosted agent status: {version_details.status} (attempt {attempt + 1}/60)") + if version_details.status == "active": + break + if version_details.status == "failed": + raise RuntimeError(f"Hosted agent provisioning failed: {dict(version_details)}") + else: + raise RuntimeError("Timed out waiting for the hosted agent version to become active") + + ensure_agent_identity_rbac( + agent=created_version, + credential=credential, + subscription_id=subscription_id, + foundry_project_endpoint=endpoint, + ) + + original_agent_endpoint = project_client.agents.get(agent_name=agent_name).agent_endpoint + endpoint_config = AgentEndpointConfig( + version_selector=VersionSelector( + version_selection_rules=[ + FixedRatioVersionSelectionRule(agent_version=created_version.version, traffic_percentage=100), + ] + ), + protocol_configuration=ProtocolConfiguration(responses=ResponsesProtocolConfiguration()), + ) + project_client.agents.update_details(agent_name=agent_name, agent_endpoint=endpoint_config) + print(f"Agent endpoint configured for version {created_version.version}") + + with project_client.get_openai_client(agent_name=agent_name) as openai_client: + print("User 1 input: 1 + 1 = ?") + response = openai_client.responses.create( + input="1 + 1 = ?", + extra_headers={"x-ms-user-identity": delegated_user_identity}, + ) + print(f"Agent: {response.output_text}") + + print( + "User 2 input: then + 10. Check out if the agent can continue the previous response chain from User 1." + ) + try: + openai_client.responses.create( + input="then + 10", + previous_response_id=response.id, + extra_headers={"x-ms-user-identity": delegated_user_identity_2}, + ) + print(f"Agent: {response.output_text}") + except NotFoundError as e: + print( + "Agent: Expected isolation behavior confirmed. " + "A different delegated user cannot continue the previous response chain and must start a new conversation." + ) + + print("User 1 input: then + 10") + response = openai_client.responses.create( + input="then + 10", + previous_response_id=response.id, + extra_headers={"x-ms-user-identity": delegated_user_identity}, + ) + print(f"Agent: {response.output_text}") + finally: + if original_agent_endpoint is not None: + project_client.agents.update_details(agent_name=agent_name, agent_endpoint=original_agent_endpoint) + print("Agent endpoint restored") + + if created_version is not None: + project_client.agents.delete_version( + agent_name=agent_name, agent_version=created_version.version, force=True + ) + print(f"Hosted agent version {created_version.version} deleted") diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_create_hosted_agent.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_create_hosted_agent.py index e22ec9036b4e..7224b484c36a 100644 --- a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_create_hosted_agent.py +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_create_hosted_agent.py @@ -53,7 +53,6 @@ AIProjectClient( endpoint=endpoint, credential=credential, - allow_preview=True, ) as project_client, ): created = project_client.agents.create_version( diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_create_hosted_agent_async.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_create_hosted_agent_async.py index 2fb2faf3493a..fdf2913d269e 100644 --- a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_create_hosted_agent_async.py +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_create_hosted_agent_async.py @@ -55,7 +55,6 @@ async def main() -> None: AIProjectClient( endpoint=endpoint, credential=credential, - allow_preview=True, ) as project_client, ): created = await project_client.agents.create_version( diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolbox_with_skill.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolbox_with_skill.py index ddc0a61a24d7..ce70c7456824 100644 --- a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolbox_with_skill.py +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolbox_with_skill.py @@ -8,7 +8,7 @@ DESCRIPTION: Demonstrates deploying a code-based Hosted Agent that discovers and uses skills from a Foundry Toolbox MCP endpoint via Agent Framework - ``SkillsProvider(MCPSkillsSource(...))``. + `FoundryToolbox()`. The sample: 1. Creates a shipping-cost skill. @@ -87,7 +87,7 @@ def main() -> None: with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, project_client.get_openai_client(agent_name=agent_name) as hosted_openai_client, ): @@ -146,17 +146,18 @@ def main() -> None: "FOUNDRY_MODEL_NAME": model_name, "MCP_SERVER_URL": toolbox_mcp_url, }, - protocol_versions=[ProtocolVersionRecord(protocol="responses", version="1.0.0")], + protocol_versions=[ProtocolVersionRecord(protocol="responses", version="2.0.0")], ), code=code_stream, ) print(f"Created hosted agent version: {created.version}") - wait_for_agent_version_active( - project_client=project_client, - agent_name=agent_name, - agent_version=created.version, - ) + if created.status != "active": + wait_for_agent_version_active( + project_client=project_client, + agent_name=agent_name, + agent_version=created.version, + ) ensure_agent_identity_rbac( agent=created, diff --git a/sdk/ai/azure-ai-projects/samples/memories/sample_memory_basic.py b/sdk/ai/azure-ai-projects/samples/memories/sample_memory_basic.py index e1d43de53d15..631d5dad73f9 100644 --- a/sdk/ai/azure-ai-projects/samples/memories/sample_memory_basic.py +++ b/sdk/ai/azure-ai-projects/samples/memories/sample_memory_basic.py @@ -46,7 +46,7 @@ with ( DefaultAzureCredential(exclude_interactive_browser_credential=False) as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): # Delete memory store, if it already exists diff --git a/sdk/ai/azure-ai-projects/samples/memories/sample_memory_basic_async.py b/sdk/ai/azure-ai-projects/samples/memories/sample_memory_basic_async.py index c3a761f1d44a..713114fa67dd 100644 --- a/sdk/ai/azure-ai-projects/samples/memories/sample_memory_basic_async.py +++ b/sdk/ai/azure-ai-projects/samples/memories/sample_memory_basic_async.py @@ -51,7 +51,7 @@ async def main() -> None: async with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): # Delete memory store, if it already exists diff --git a/sdk/ai/azure-ai-projects/samples/skills/sample_skills_crud.py b/sdk/ai/azure-ai-projects/samples/skills/sample_skills_crud.py index 76ae203ea2b6..62d712e0e749 100644 --- a/sdk/ai/azure-ai-projects/samples/skills/sample_skills_crud.py +++ b/sdk/ai/azure-ai-projects/samples/skills/sample_skills_crud.py @@ -50,7 +50,7 @@ def print_skill_version_description(version: SkillVersion) -> None: with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): skill_name = "product-support-skill" diff --git a/sdk/ai/azure-ai-projects/samples/skills/sample_skills_crud_async.py b/sdk/ai/azure-ai-projects/samples/skills/sample_skills_crud_async.py index a200d920c8ff..1d2183aa2c99 100644 --- a/sdk/ai/azure-ai-projects/samples/skills/sample_skills_crud_async.py +++ b/sdk/ai/azure-ai-projects/samples/skills/sample_skills_crud_async.py @@ -53,7 +53,7 @@ async def main() -> None: async with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): skill_name = "product-support-skill" diff --git a/sdk/ai/azure-ai-projects/samples/skills/sample_skills_upload_and_download.py b/sdk/ai/azure-ai-projects/samples/skills/sample_skills_upload_and_download.py index ce39661f87cc..205f7e84a956 100644 --- a/sdk/ai/azure-ai-projects/samples/skills/sample_skills_upload_and_download.py +++ b/sdk/ai/azure-ai-projects/samples/skills/sample_skills_upload_and_download.py @@ -62,7 +62,7 @@ with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): try: diff --git a/sdk/ai/azure-ai-projects/samples/skills/sample_skills_upload_and_download_async.py b/sdk/ai/azure-ai-projects/samples/skills/sample_skills_upload_and_download_async.py index a9044c056a74..86dbb792bc45 100644 --- a/sdk/ai/azure-ai-projects/samples/skills/sample_skills_upload_and_download_async.py +++ b/sdk/ai/azure-ai-projects/samples/skills/sample_skills_upload_and_download_async.py @@ -65,7 +65,7 @@ async def main() -> None: async with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): try: diff --git a/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_responses_instrumentor.py b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_responses_instrumentor.py index 1133311f2293..9fcc26ce28d8 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_responses_instrumentor.py +++ b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_responses_instrumentor.py @@ -4967,7 +4967,7 @@ def test_workflow_agent_non_streaming_with_content_recording( assert True == AIProjectInstrumentor().is_content_recording_enabled() with self.create_client(operation_group="tracing", allow_preview=True, **kwargs) as project_client: - deployment_name = kwargs.get("foundry_model_name") + deployment_name: str = kwargs["foundry_model_name"] openai_client = project_client.get_openai_client() # Create Teacher Agent @@ -5288,7 +5288,7 @@ def test_workflow_agent_streaming_with_content_recording( assert True == AIProjectInstrumentor().is_content_recording_enabled() with self.create_client(operation_group="tracing", allow_preview=True, **kwargs) as project_client: - deployment_name = kwargs.get("foundry_model_name") + deployment_name: str = kwargs["foundry_model_name"] openai_client = project_client.get_openai_client() # Create Teacher Agent diff --git a/sdk/ai/azure-ai-projects/tests/agents/test_agents_crud.py b/sdk/ai/azure-ai-projects/tests/agents/test_agents_crud.py index 51040ba9f8e2..bca762f98b26 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/test_agents_crud.py +++ b/sdk/ai/azure-ai-projects/tests/agents/test_agents_crud.py @@ -243,7 +243,7 @@ def test_prompt_agent_endpoint_responses(self, **kwargs): assert model is not None agent_name = "PromptAgentEndpointTestAgent" - project_client = self.create_client(operation_group="agents", allow_preview=True, **kwargs) + project_client = self.create_client(operation_group="agents", **kwargs) agent = project_client.agents.create_version( agent_name=agent_name, definition=PromptAgentDefinition( diff --git a/sdk/ai/azure-ai-projects/tests/agents/test_agents_crud_async.py b/sdk/ai/azure-ai-projects/tests/agents/test_agents_crud_async.py index 17f4d7f01113..5a3a9623616d 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/test_agents_crud_async.py +++ b/sdk/ai/azure-ai-projects/tests/agents/test_agents_crud_async.py @@ -235,7 +235,7 @@ async def test_prompt_agent_endpoint_responses_async(self, **kwargs): assert model is not None agent_name = "PromptAgentEndpointTestAgent" - project_client = self.create_async_client(operation_group="agents", allow_preview=True, **kwargs) + project_client = self.create_async_client(operation_group="agents", **kwargs) async with project_client: agent = await project_client.agents.create_version( diff --git a/sdk/ai/azure-ai-projects/tests/responses/test_openai_client_endpoint.py b/sdk/ai/azure-ai-projects/tests/responses/test_openai_client_endpoint.py index f5a5dd6ba9f2..227379441bc0 100644 --- a/sdk/ai/azure-ai-projects/tests/responses/test_openai_client_endpoint.py +++ b/sdk/ai/azure-ai-projects/tests/responses/test_openai_client_endpoint.py @@ -49,12 +49,11 @@ def test_get_openai_client_default_endpoint(self): expected_base_url = FAKE_ENDPOINT.rstrip("/") + "/openai/v1" assert str(openai_client.base_url).rstrip("/") == expected_base_url - def test_get_openai_client_with_agent_name_and_allow_preview(self): - """Verify that the OpenAI client base_url includes the agent endpoint when allow_preview=True.""" + def test_get_openai_client_with_agent_name(self): + """Verify that the OpenAI client base_url includes the agent endpoint.""" project_client = AIProjectClient( endpoint=FAKE_ENDPOINT, credential=FakeCredential(), # type: ignore[arg-type] - allow_preview=True, ) openai_client = project_client.get_openai_client(agent_name=AGENT_NAME) diff --git a/sdk/ai/azure-ai-projects/tests/responses/test_openai_client_endpoint_async.py b/sdk/ai/azure-ai-projects/tests/responses/test_openai_client_endpoint_async.py index 097fb21e8f9c..2d128b34240b 100644 --- a/sdk/ai/azure-ai-projects/tests/responses/test_openai_client_endpoint_async.py +++ b/sdk/ai/azure-ai-projects/tests/responses/test_openai_client_endpoint_async.py @@ -57,12 +57,11 @@ async def test_get_openai_client_default_endpoint_async(self): assert str(openai_client.base_url).rstrip("/") == expected_base_url @pytest.mark.asyncio - async def test_get_openai_client_with_agent_name_and_allow_preview_async(self): - """Verify that the async OpenAI client base_url includes the agent endpoint when allow_preview=True.""" + async def test_get_openai_client_with_agent_name(self): + """Verify that the async OpenAI client base_url includes the agent endpoint.""" project_client = AIProjectClient( endpoint=FAKE_ENDPOINT, credential=FakeAsyncCredential(), # type: ignore[arg-type] - allow_preview=True, ) openai_client = project_client.get_openai_client(agent_name=AGENT_NAME) diff --git a/sdk/ai/azure-ai-projects/tests/samples/assets/basic-agent.zip b/sdk/ai/azure-ai-projects/tests/samples/assets/basic-agent.zip new file mode 100644 index 000000000000..dea089006de5 Binary files /dev/null and b/sdk/ai/azure-ai-projects/tests/samples/assets/basic-agent.zip differ diff --git a/sdk/ai/azure-ai-projects/tests/samples/assets/toolbox-agent.zip b/sdk/ai/azure-ai-projects/tests/samples/assets/toolbox-agent.zip index bd84b0e31afa..4280ae5bf8a6 100644 Binary files a/sdk/ai/azure-ai-projects/tests/samples/assets/toolbox-agent.zip and b/sdk/ai/azure-ai-projects/tests/samples/assets/toolbox-agent.zip differ diff --git a/sdk/ai/azure-ai-projects/tests/samples/repair_hosted_agent_sample_recording.py b/sdk/ai/azure-ai-projects/tests/samples/repair_hosted_agent_sample_recording.py new file mode 100644 index 000000000000..1dc77028cc1a --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/samples/repair_hosted_agent_sample_recording.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +"""Repair hosted-agent sample recordings with unsanitized multipart metadata. + +This script rewrites existing asset recordings in place. It targets the +`agents.create_version_from_code(...)` multipart request shape used by hosted +agent code-upload samples: + +1. Find request entries whose multipart body contains a `metadata` part. +2. Decode the `b64:`-encoded JSON payload for that part. +3. Sanitize hosted-agent environment variable values and related fields. +4. Re-encode the payload and update the recorded `Content-Length` header. + +The repair is intended for cases where a recording is already bad and you want +to salvage it without doing a fresh live re-record. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import re +from copy import deepcopy +from pathlib import Path +from typing import Any + +SANITIZED_ACCOUNT_NAME = "sanitized-account-name" +SANITIZED_PROJECT_NAME = "sanitized-project-name" +SANITIZED_HOSTED_AGENT_NAME = "sanitized-hosted-agent-name" +SANITIZED_MODEL_DEPLOYMENT_NAME = "sanitized-model-deployment-name" +SANITIZED_PROJECT_ENDPOINT = ( + f"https://{SANITIZED_ACCOUNT_NAME}.services.ai.azure.com/api/projects/{SANITIZED_PROJECT_NAME}" +) +SANITIZED_MODEL_ENDPOINT = f"https://{SANITIZED_ACCOUNT_NAME}.services.ai.azure.com" + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[5] + + +def _package_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _recordings_roots() -> list[Path]: + package_relative_path = _package_root().relative_to(_repo_root()) + recordings_roots = [] + for asset_dir in sorted((_repo_root() / ".assets").glob("*")): + recordings_root = asset_dir / "python" / package_relative_path / "tests" / "recordings" / "samples" + if recordings_root.is_dir(): + recordings_roots.append(recordings_root) + return recordings_roots + + +def _node_id_to_recording_filename(node_id: str) -> str: + node_parts = node_id.split("::") + if len(node_parts) < 2: + raise ValueError( + "Test id must be a pytest node id like " "'path/to/test_file.py::TestClass::test_method[param]'" + ) + + file_name = Path(node_parts[0]).name + return f"{file_name}{''.join(node_parts[1:])}.json" + + +def _resolve_recording_target(target: str) -> Path: + candidate_path = Path(target) + if candidate_path.is_file(): + return candidate_path + + if "::" not in target: + raise FileNotFoundError(f"Recording file not found: {target}") + + recording_filename = _node_id_to_recording_filename(target) + matches = [root / recording_filename for root in _recordings_roots() if (root / recording_filename).is_file()] + + if not matches: + raise FileNotFoundError( + f"Could not resolve pytest node id to a recording file: {target}\n" + f"Expected filename: {recording_filename}" + ) + + if len(matches) > 1: + matches_text = "\n".join(str(match) for match in matches) + raise FileExistsError(f"Pytest node id matched multiple recording files for {target}:\n{matches_text}") + + return matches[0] + + +def _sanitize_string(value: str) -> str: + """Normalize hosted-agent values commonly leaked into sample recordings.""" + if not value: + return value + + if re.fullmatch(r"https?://[^/]+\.services\.ai\.azure\.com/api/projects/[^/?#]+", value): + return SANITIZED_PROJECT_ENDPOINT + + if re.fullmatch(r"https?://[^/]+\.services\.ai\.azure\.com/?", value): + return SANITIZED_MODEL_ENDPOINT + + if "/api/projects/" in value and "/toolboxes/" in value and "/mcp" in value: + return re.sub( + r"https?://[^/]+\.services\.ai\.azure\.com/api/projects/[^/?#]+", + SANITIZED_PROJECT_ENDPOINT, + value, + ) + + if value.startswith("gpt-"): + return SANITIZED_MODEL_DEPLOYMENT_NAME + + if value.startswith("hosted-") or value.startswith("sanitized-hosted-agent-name"): + return SANITIZED_HOSTED_AGENT_NAME + + return value + + +def _sanitize_env_vars(environment_variables: dict[str, Any]) -> bool: + changed = False + replacements = { + "FOUNDRY_PROJECT_ENDPOINT": SANITIZED_PROJECT_ENDPOINT, + "MODEL_ENDPOINT": SANITIZED_MODEL_ENDPOINT, + "FOUNDRY_MODEL_NAME": SANITIZED_MODEL_DEPLOYMENT_NAME, + "MODEL_DEPLOYMENT_NAME": SANITIZED_MODEL_DEPLOYMENT_NAME, + "AZURE_AI_MODEL_DEPLOYMENT_NAME": SANITIZED_MODEL_DEPLOYMENT_NAME, + "AGENT_NAME": SANITIZED_HOSTED_AGENT_NAME, + } + + for key, replacement in replacements.items(): + if key in environment_variables and environment_variables[key] != replacement: + environment_variables[key] = replacement + changed = True + + if "MCP_SERVER_URL" in environment_variables: + sanitized_value = _sanitize_string(str(environment_variables["MCP_SERVER_URL"])) + if environment_variables["MCP_SERVER_URL"] != sanitized_value: + environment_variables["MCP_SERVER_URL"] = sanitized_value + changed = True + + return changed + + +def _sanitize_metadata_payload(metadata_payload: dict[str, Any]) -> bool: + changed = False + + definition = metadata_payload.get("definition") + if isinstance(definition, dict): + environment_variables = definition.get("environment_variables") + if isinstance(environment_variables, dict): + changed = _sanitize_env_vars(environment_variables) or changed + + description = metadata_payload.get("description") + if isinstance(description, str): + sanitized_description = _sanitize_string(description) + if sanitized_description != description: + metadata_payload["description"] = sanitized_description + changed = True + + return changed + + +def _sanitize_response_body(response_body: Any) -> bool: + changed = False + if not isinstance(response_body, dict): + return changed + + # Only touch hosted-agent version responses. Other sample resources like + # skills/toolboxes also carry a `name` field, and rewriting them changes + # later playback request bodies. + if response_body.get("object") != "agent.version": + return False + + if isinstance(response_body.get("name"), str) and response_body["name"] != SANITIZED_HOSTED_AGENT_NAME: + response_body["name"] = SANITIZED_HOSTED_AGENT_NAME + changed = True + + definition = response_body.get("definition") + if isinstance(definition, dict): + environment_variables = definition.get("environment_variables") + if isinstance(environment_variables, dict): + changed = _sanitize_env_vars(environment_variables) or changed + + return changed + + +def _repair_request_body_lines(request_body: list[str]) -> tuple[list[str], bool]: + lines = list(request_body) + changed = False + + for index, line in enumerate(lines): + if not isinstance(line, str) or not line.startswith("b64:"): + continue + + previous_line = lines[index - 1] if index > 0 else "" + if previous_line != "\r\n": + continue + + disposition_line = lines[index - 2] if index > 1 else "" + if 'name="metadata"' not in disposition_line: + continue + + decoded = base64.b64decode(line[4:]).decode("utf-8") + metadata_payload = json.loads(decoded) + original_payload = deepcopy(metadata_payload) + payload_changed = _sanitize_metadata_payload(metadata_payload) + normalized_json = json.dumps(metadata_payload) + formatting_changed = decoded != normalized_json + if not payload_changed and not formatting_changed: + continue + + if payload_changed or formatting_changed or metadata_payload != original_payload: + # Match the runtime multipart JSON formatting closely so playback + # body comparison does not fail on whitespace-only differences. + encoded = base64.b64encode(normalized_json.encode("utf-8")).decode("ascii") + lines[index] = f"b64:{encoded}" + changed = True + + return lines, changed + + +def _update_content_length(entry: dict[str, Any]) -> bool: + request_body = entry.get("RequestBody") + request_headers = entry.get("RequestHeaders") + if not isinstance(request_body, list) or not isinstance(request_headers, dict): + return False + + content_type = str(request_headers.get("Content-Type", "")) + if "multipart/form-data" not in content_type: + return False + + total_length = 0 + for line in request_body: + if isinstance(line, str) and line.startswith("b64:"): + total_length += len(base64.b64decode(line[4:])) + elif isinstance(line, str): + total_length += len(line.encode("utf-8")) + + content_length = str(total_length) + if request_headers.get("Content-Length") != content_length: + request_headers["Content-Length"] = content_length + return True + + return False + + +def repair_recording(recording_path: Path) -> tuple[bool, int]: + payload = json.loads(recording_path.read_text(encoding="utf-8")) + changed = False + repaired_entries = 0 + + if isinstance(payload, dict) and isinstance(payload.get("Entries"), list): + entries = payload["Entries"] + elif isinstance(payload, list): + entries = payload + else: + raise ValueError(f"Recording file does not contain a supported recording entry list: {recording_path}") + + for entry in entries: + if not isinstance(entry, dict): + continue + + request_body = entry.get("RequestBody") + if isinstance(request_body, list): + repaired_request_body, body_changed = _repair_request_body_lines(request_body) + if body_changed: + entry["RequestBody"] = repaired_request_body + changed = True + repaired_entries += 1 + + response_body = entry.get("ResponseBody") + if _sanitize_response_body(response_body): + changed = True + + if _update_content_length(entry): + changed = True + + if changed: + recording_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + return changed, repaired_entries + + +def main() -> int: + parser = argparse.ArgumentParser(description="Repair bad hosted-agent sample recordings in place.") + parser.add_argument( + "recording", + help=( + "Path to the recording JSON file to repair, or a pytest node id like " + "'path/to/test_file.py::TestClass::test_method[param]'" + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Report whether changes would be made without writing the file.", + ) + args = parser.parse_args() + + try: + recording_path = _resolve_recording_target(args.recording) + except (FileExistsError, FileNotFoundError, ValueError) as exc: + parser.error(str(exc)) + + original_text = recording_path.read_text(encoding="utf-8") + changed, repaired_entries = repair_recording(recording_path) + + if args.dry_run: + recording_path.write_text(original_text, encoding="utf-8") + + if changed: + action = "Would repair" if args.dry_run else "Repaired" + repaired_entry_label = "entry" if repaired_entries == 1 else "entries" + print(f"{action} {repaired_entries} multipart metadata {repaired_entry_label} in {recording_path}") + else: + print(f"No hosted-agent multipart metadata changes needed in {recording_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/ai/azure-ai-projects/tests/samples/test_samples.py b/sdk/ai/azure-ai-projects/tests/samples/test_samples.py index a963cf08f611..2ad572ff0bf6 100644 --- a/sdk/ai/azure-ai-projects/tests/samples/test_samples.py +++ b/sdk/ai/azure-ai-projects/tests/samples/test_samples.py @@ -3,8 +3,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -import pytest import os +import pytest from devtools_testutils import recorded_by_proxy, AzureRecordedTestCase, RecordedTransport from test_base import servicePreparer, fineTuningServicePreparer, modelsServicePreparer from sample_executor import ( @@ -41,8 +41,8 @@ class TestSamples(AzureRecordedTestCase): get_sample_paths( "agents/tools", samples_to_skip=[ - "sample_agent_file_search_structured_inputs.py", # No issue to run. Just posepone recording. - "sample_agent_code_interpreter_structured_inputs.py", # No issue to run. Just posepone recording. + "sample_agent_file_search_structured_inputs.py", # No issue to run. Just postpone recording. + "sample_agent_code_interpreter_structured_inputs.py", # No issue to run. Just postpone recording. "sample_agent_azure_function.py", # In the list of additional sample tests above due to more parameters needed "sample_agent_computer_use.py", # 400 BadRequestError: Invalid URI (URI string too long) "sample_agent_browser_automation.py", # APITimeoutError: request timed out @@ -265,6 +265,16 @@ def test_chat_completions_samples(self, sample_path: str, **kwargs) -> None: "SKIP_RBAC": "true", }, ), + AdditionalSampleTestDetail( + test_id="sample_agent_user_identity_isolation", + sample_filename="sample_agent_user_identity_isolation.py", + env_vars={ + "ZIP_FILE_PATH": "tests/samples/assets/basic-agent.zip", + "DELEGATED_USER_IDENTITY": "86636782-5c1b-455e-b25f-91fc467ac05d", + "DELEGATED_USER_IDENTITY_2": "340fcd8b-b87e-41d5-b4d5-fc02df14e807", + "SKIP_RBAC": "true", + }, + ), ] ) @pytest.mark.parametrize( @@ -275,6 +285,7 @@ def test_chat_completions_samples(self, sample_path: str, **kwargs) -> None: "sample_create_hosted_agent.py", # Specified through AdditionalSampleTestDetail "sample_toolbox_with_skill.py", # Specified through AdditionalSampleTestDetail "sample_create_hosted_agent_from_code.py", # Specified through AdditionalSampleTestDetail + "sample_agent_user_identity_isolation.py", # Specified through AdditionalSampleTestDetail ], ), ) @@ -285,6 +296,13 @@ def test_hosted_agents_samples(self, sample_path: str, **kwargs) -> None: env_vars = get_sample_env_vars(kwargs) executor = SyncSampleExecutor(self, sample_path, env_vars=env_vars, **kwargs) executor.execute() + + if os.path.basename(sample_path) == "sample_agent_user_identity_isolation.py": + # This sample intentionally exercises a wrong-user 404 branch to + # prove response-chain isolation, so execution success is the + # authoritative validation signal for this case. + return + executor.validate_print_calls_by_llm() @additionalSampleTests(