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 deleted file mode 100644 index f4e0e7de9e38..000000000000 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_toolbox_skill.py +++ /dev/null @@ -1,155 +0,0 @@ -# pylint: disable=line-too-long,useless-suppression -# ------------------------------------ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# ------------------------------------ - -""" -DESCRIPTION: - This sample demonstrates how to expose a Skill to a Prompt Agent via a - Toolbox, using the synchronous AIProjectClient and the OpenAI-compatible - client. - - It creates a Skill with inline content describing how to compute shipping - cost, then creates a Toolbox version that references the skill. A Prompt - Agent is created with an `MCPTool` pointed at the toolbox's versioned - `/mcp` endpoint. The skill's instructions are injected into the agent's - context, so when asked a shipping-cost question the agent answers directly - using the skill's formula. - - Skills are currently a preview features. In the Python SDK, - you access these operations via `project_client.beta.skills`. - -USAGE: - python sample_agent_toolbox_skill.py - - Before running the sample: - - pip install "azure-ai-projects>=2.3.0" python-dotenv openai - - Set these environment variables with your own values: - 1) FOUNDRY_PROJECT_ENDPOINT - The Azure AI Project endpoint, as found in the - Overview page of your Microsoft Foundry portal. - 2) FOUNDRY_MODEL_NAME - The deployment name of the AI model, as found under - the "Name" column in the "Models + endpoints" tab in your Microsoft - Foundry project. - 3) FOUNDRY_AGENT_NAME - Optional. The name of the AI agent. If not set, defaults to "MyAgent". -""" - -import os - -from dotenv import load_dotenv - -from azure.ai.projects.models._models import ToolboxSearchPreviewToolboxTool -from azure.core.exceptions import ResourceNotFoundError -from azure.identity import DefaultAzureCredential - -from azure.ai.projects import AIProjectClient -from azure.ai.projects.models import ( - MCPTool, - PromptAgentDefinition, - SkillInlineContent, - ToolboxSearchPreviewToolboxTool, - ToolboxSkillReference, -) -from util import create_version_with_endpoint - -load_dotenv() - -endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] - -SKILL_NAME = "shipping-cost-skill" -TOOLBOX_NAME = "toolbox_with_skill" -agent_name = os.environ.get("FOUNDRY_AGENT_NAME", "MyAgent") - - -with ( - DefaultAzureCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential) as project_client, - project_client.get_openai_client(agent_name=agent_name) as openai_client, -): - - try: - project_client.toolboxes.delete(TOOLBOX_NAME) - except ResourceNotFoundError: - pass - - try: - project_client.beta.skills.delete(SKILL_NAME) - except ResourceNotFoundError: - pass - - skill_version = project_client.beta.skills.create( - name=SKILL_NAME, - inline_content=SkillInlineContent( - description="Compute shipping cost for a package given weight and destination.", - instructions=( - "You are a shipping cost calculator. When asked to compute " - "shipping cost, use this formula: cost (USD) = 5 + 2 * weight_kg " - "for domestic destinations, and cost (USD) = 15 + 4 * weight_kg " - "for international destinations. Always state the formula you used." - ), - metadata={"revision": "1"}, - ), - ) - print(f"Created skill: {skill_version.name} version={skill_version.version}") - - toolbox_version = project_client.toolboxes.create_version( - name=TOOLBOX_NAME, - description="Toolbox exposing a shipping-cost skill.", - tools=[ToolboxSearchPreviewToolboxTool()], - skills=[ToolboxSkillReference(name=skill_version.name, version=skill_version.version)], - ) - print(f"Created toolbox: {toolbox_version.name} version={toolbox_version.version}") - - toolbox_mcp_url = f"{endpoint}/toolboxes/{TOOLBOX_NAME}/versions/{toolbox_version.version}/mcp?api-version=v1" - token = credential.get_token("https://ai.azure.com/.default").token - - toolbox_mcp_tool = MCPTool( - server_label="skill-toolbox", - server_url=toolbox_mcp_url, - authorization=token, - require_approval="never", - ) - - with create_version_with_endpoint( - project_client=project_client, - agent_name=agent_name, - definition=PromptAgentDefinition( - model=os.environ["FOUNDRY_MODEL_NAME"], - instructions=( - "Answer the user using the `shipping-cost-skill` instructions " - "available in your context. Do not call `tool_search`; the " - "skill rules are already part of your knowledge. Apply the " - "skill's formula exactly as given and state the formula in " - "your answer." - ), - temperature=0, - tools=[toolbox_mcp_tool], - ), - ) as agent: - - user_input = "Compute the shipping cost for a 3 kg package shipped domestically." - print(f"User: {user_input}") - response = openai_client.responses.create( - input=user_input, - ) - - for item in response.output: - if item.type == "mcp_list_tools": - print(f"mcp_list_tools server_label={item.server_label} tools={[t.name for t in (item.tools or [])]}") - elif item.type == "mcp_call": - print(f"mcp_call server_label={item.server_label} name={item.name} error={item.error}") - if getattr(item, "output", None): - print(f" output: {item.output}") - elif item.type == "mcp_approval_request": - print(f"mcp_approval_request server_label={item.server_label} name={item.name}") - else: - print(f"output item type={item.type}") - - print(f"Response: {response.output_text}") - - project_client.toolboxes.delete(TOOLBOX_NAME) - print("Toolbox deleted") - project_client.beta.skills.delete(SKILL_NAME) - print("Skill deleted") 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 51ba81ad11b6..f0ba05195eca 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 @@ -9,42 +9,28 @@ from azure.identity import DefaultAzureCredential from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() async def main() -> None: credential = DefaultAzureCredential() - # 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) + toolbox = FoundryToolbox(url=os.environ["MCP_SERVER_URL"], credential=credential) - # 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() + # set disable_load_skill_approval to avoid approval required for loading skills + skills_provider = toolbox.as_skills_provider(disable_load_skill_approval=True) client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=os.environ["FOUNDRY_MODEL_NAME"], credential=credential, + allow_preview=True, ) 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) 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 74e9d565df41..fd97a8982681 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,3 +1,3 @@ -agent-framework-foundry==1.10.0 -agent-framework-foundry-hosting>=1.0.0a260630 +agent-framework-foundry==1.10.2 +agent-framework-foundry-hosting==1.0.0b260721 python-dotenv diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/rbac_util.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/rbac_util.py new file mode 100644 index 000000000000..8e97fdaa09fd --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/rbac_util.py @@ -0,0 +1,132 @@ +import os +import uuid +from typing import Any, cast +from urllib.parse import urlparse + +from azure.core.credentials import TokenCredential +from azure.core.exceptions import ResourceNotFoundError +from azure.ai.projects.models import AgentVersionDetails + +AZURE_AI_USER_ROLE_DEFINITION_GUID = "53ca6127-db72-4b80-b1b0-d745d6d5456d" + + +def _extract_resource_group_name(resource_id: str) -> str: + parts = resource_id.strip("/").split("/") + for index, part in enumerate(parts): + if part.lower() == "resourcegroups" and index + 1 < len(parts): + return parts[index + 1] + return "" + + +def _resolve_ai_account_resource_id( + credential: TokenCredential, + account_name: str, + project_name: str, + subscription_id: str, +) -> str: + from azure.mgmt.resource.resources import ResourceManagementClient + + resource_client = ResourceManagementClient(credential, subscription_id) + project_resources = resource_client.resources.list( + filter="resourceType eq 'Microsoft.CognitiveServices/accounts/projects'" + ) + + project_id_segment = f"/accounts/{account_name}/projects/{project_name}".lower() + matching_projects = [ + resource for resource in project_resources if resource.id and project_id_segment in resource.id.lower() + ] + if not matching_projects: + raise RuntimeError(f"Could not locate Foundry project '{project_name}' in subscription '{subscription_id}'.") + + if not matching_projects[0].id: + raise RuntimeError("Foundry project resource ID is empty.") + resource_group_name = _extract_resource_group_name(matching_projects[0].id) + account_resources = resource_client.resources.list_by_resource_group( + resource_group_name=resource_group_name, + filter="resourceType eq 'Microsoft.CognitiveServices/accounts'", + ) + + account_matches = [resource.id for resource in account_resources if resource.name == account_name and resource.id] + if not account_matches: + raise RuntimeError( + f"Could not locate Azure AI account '{account_name}' in resource group '{resource_group_name}'." + ) + return account_matches[0] + + +def _ensure_agent_identity_rbac_with_role_id( + credential: TokenCredential, principal_id: str, scope_resource_id: str, subscription_id: str, role_id: str +) -> tuple[bool, str]: + from azure.mgmt.authorization import AuthorizationManagementClient, models as authorization_models + + authorization_client = AuthorizationManagementClient(credential, subscription_id) + role_definition_id = f"/subscriptions/{subscription_id}/providers/Microsoft.Authorization/roleDefinitions/{role_id}" + role_assignment_name = str( + uuid.uuid5( + uuid.NAMESPACE_URL, + f"{scope_resource_id}|{principal_id}|{role_definition_id}", + ) + ) + + try: + authorization_client.role_assignments.get(scope_resource_id, role_assignment_name) + print(f"Foundry User role already assigned to principal {principal_id}.") + return False, role_assignment_name + except ResourceNotFoundError: + pass + + create_parameters_kwargs = cast( + dict[str, Any], + { + "role_definition_id": role_definition_id, + "principal_id": principal_id, + "principal_type": authorization_models.PrincipalType.SERVICE_PRINCIPAL, + }, + ) + parameters = authorization_models.RoleAssignmentCreateParameters(**create_parameters_kwargs) + + authorization_client.role_assignments.create(scope_resource_id, role_assignment_name, parameters) + print(f"Assigned Foundry User role to principal {principal_id} at scope {scope_resource_id}.") + return True, role_assignment_name + + +def ensure_agent_identity_rbac( + agent: AgentVersionDetails, + credential: TokenCredential, + subscription_id: str, + foundry_project_endpoint: str, +) -> None: + """Ensure the hosted agent identity has Foundry User role on the Foundry account. + + :param agent: Agent version details containing ``instance_identity``. + :type agent: ~azure.ai.projects.models.AgentVersionDetails + :param credential: Credential used for Azure Resource Manager authorization calls. + :type credential: ~azure.core.credentials.TokenCredential + :param subscription_id: Azure subscription ID containing the Foundry project/account. + :type subscription_id: str + :param foundry_project_endpoint: Foundry project endpoint in the format + ``https://.services.ai.azure.com/api/projects/``. + :type foundry_project_endpoint: str + :raises RuntimeError: If the agent identity principal ID is unavailable, or if the + account/project resources cannot be resolved. + :raises ~azure.core.exceptions.HttpResponseError: If role assignment creation fails + for reasons other than an existing assignment. + """ + if os.environ.get("SKIP_RBAC"): + print("Skipping RBAC setup.") + return + if not agent.instance_identity or not agent.instance_identity.principal_id: + raise RuntimeError("Agent instance_identity or principal_id is not available.") + principal_id = agent.instance_identity.principal_id + + account_name = urlparse(foundry_project_endpoint).hostname.split(".")[0] # type: ignore[union-attr] + project_name = foundry_project_endpoint.rstrip("/").split("/api/projects/")[1].split("/")[0] + scope_resource_id = _resolve_ai_account_resource_id(credential, account_name, project_name, subscription_id) + + _ensure_agent_identity_rbac_with_role_id( + credential=credential, + principal_id=principal_id, + scope_resource_id=scope_resource_id, + subscription_id=subscription_id, + role_id=AZURE_AI_USER_ROLE_DEFINITION_GUID, + ) diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_routines_with_teams_message_trigger.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_routines_with_teams_message_trigger.py new file mode 100644 index 000000000000..e99fbd1c8091 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_routines_with_teams_message_trigger.py @@ -0,0 +1,212 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + This sample demonstrates how to create a Routine that fires when a new + Microsoft Teams channel message arrives, then record the resulting runs by + polling `list_runs(...)` using the synchronous AIProjectClient. + + The sample uploads the basic hosted-agent code from `assets/basic-agent/` + as a temporary hosted-agent version, routes the configured hosted agent + name to that version, and creates a routine configured with a + `CustomRoutineTrigger`. The trigger uses a Teams-compatible custom + connection and listens for the `on_new_channel_message` event on a specific + Teams channel. After creating the routine, post a message to the configured + channel to fire it. The sample polls the routine run history for a short + period and then deletes the routine and hosted-agent version. + + Routines are currently a preview feature. In the Python SDK, you access + these operations via `project_client.beta.routines`. + +USAGE: + python sample_routines_with_teams_message_trigger.py + + Before running the sample: + + pip install "azure-ai-projects>=2.2.0" python-dotenv + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - The Azure AI Project endpoint, as found in the Overview + page of your Microsoft Foundry portal. + 2) FOUNDRY_MODEL_NAME - The deployment name of the AI model used by the + temporary hosted agent. + 3) FOUNDRY_HOSTED_AGENT_NAME - Optional. The hosted agent name to route to + the temporary uploaded version. Defaults to `MyHostedAgent`. + 4) TEAMS_CONNECTION_NAME - The Teams custom connection ID or name. + Defaults to `teams-conn`. + 5) TEAMS_CHANNEL_URL - A Teams channel URL like the sample URL + below. When set, the sample derives `groupId` and `channelId` from it. + 6) POLL_INTERVAL_SECONDS - Optional. Seconds to sleep between run-history polls. + Defaults to 10. + + Sample channel: + https://teams.microsoft.com/l/channel//?groupId=&tenantId= +""" + +import json +import os +import time +from urllib.parse import parse_qs, unquote, urlparse + +from dotenv import load_dotenv + +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import DefaultAzureCredential + +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import ( + CodeConfiguration, + CustomRoutineTrigger, + HostedAgentDefinition, + InvokeAgentResponsesApiRoutineAction, + ProtocolVersionRecord, + RoutineRun, +) + +from hosted_agents_util import create_version_from_code, select_basic_agent_code_zip + + +def parse_teams_channel_url(channel_url: str) -> tuple[str | None, str | None]: + parsed = urlparse(channel_url) + path_parts = [part for part in parsed.path.split("/") if part] + + channel_id = None + if len(path_parts) >= 3 and path_parts[0] == "l" and path_parts[1] == "channel": + channel_id = unquote(path_parts[2]) + + query = parse_qs(parsed.query) + group_id = query.get("groupId", [None])[0] + return group_id, channel_id + + +load_dotenv() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +agent_name = os.environ.get("FOUNDRY_HOSTED_AGENT_NAME", "MyHostedAgent") +model_name = os.environ["FOUNDRY_MODEL_NAME"] +teams_connection_name = os.environ["TEAMS_CONNECTION_NAME"] +teams_channel_url = os.environ["TEAMS_CHANNEL_URL"] +teams_group_id, teams_channel_id = parse_teams_channel_url(teams_channel_url) +poll_interval_seconds = int(os.environ.get("POLL_INTERVAL_SECONDS", "10")) +use_remote_build = os.environ.get("FOUNDRY_HOSTED_AGENT_REMOTE_BUILD", "true").strip().lower() == "true" +dependency_resolution, code_zip_stream = select_basic_agent_code_zip(True) + + +def main() -> None: + with ( + code_zip_stream as code_stream, + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, + create_version_from_code( + project_client=project_client, + agent_name=agent_name, + description="Teams channel routine sample hosted agent uploaded from assets/basic-agent.", + definition=HostedAgentDefinition( + cpu="0.5", + memory="1Gi", + code_configuration=CodeConfiguration( + runtime="python_3_14", + entry_point=["python", "main.py"], + dependency_resolution=dependency_resolution, + ), + environment_variables={ + "FOUNDRY_PROJECT_ENDPOINT": endpoint, + "FOUNDRY_MODEL_NAME": model_name, + }, + protocol_versions=[ + ProtocolVersionRecord(protocol="responses", version="2.0.0"), + ProtocolVersionRecord(protocol="invocations", version="2.0.0"), + ], + ), + metadata={"enableVnextExperience": "true"}, + code=code_stream, + ), + ): + routine_name = "sample-routine-teams-channel-message" + + print(f"Preparing routine `{routine_name}` for Teams channel {teams_channel_id}.") + print(f"Using Teams channel URL: {teams_channel_url}") + print({"group_id": teams_group_id, "channel_id": teams_channel_id}) + try: + print(f"Deleting any existing routine `{routine_name}`.") + project_client.beta.routines.delete(routine_name) + print(f"Routine `{routine_name}` deleted") + except ResourceNotFoundError: + pass + + print(f"Creating routine `{routine_name}`.") + created = project_client.beta.routines.create_or_update( + routine_name, + description="Routine used by the Teams channel message trigger sample.", + enabled=True, + triggers={ + "incoming": CustomRoutineTrigger( + provider="teams", + event_name="on_new_channel_message", + parameters={ + "connection_id": teams_connection_name, + "thread_type": "channel", + "group_id": teams_group_id, + "channel_id": teams_channel_id, + }, + ) + }, + action=InvokeAgentResponsesApiRoutineAction(agent_name=agent_name), + ) + print( + f"Created routine: {created.name} enabled={created.enabled} " + f"provider=teams event_name=on_new_channel_message group_id={teams_group_id}" + ) + print("Post a new message to the configured Teams channel to fire the routine.") + print("Waiting for a routine run for up to 10 minutes...") + + try: + seen_phases: dict[str, str] = {} + final_run: RoutineRun | None = None + run_was_triggered = False + terminal_statuses = {"finished", "failed", "killed"} + + deadline = time.monotonic() + 600 + while deadline > time.monotonic(): + runs = list(project_client.beta.routines.list_runs(routine_name, limit=20, order="desc")) + for run in runs: + run_was_triggered = True + current_phase = str(run.phase) + if seen_phases.get(run.id) == current_phase: + continue + seen_phases[run.id] = current_phase + print( + f" - run_id={run.id} phase={run.phase} status={run.status} " + f"trigger_type={run.trigger_type} triggered_at={run.triggered_at} ended_at={run.ended_at}" + ) + if str(run.status).lower() in terminal_statuses: + final_run = run + + if final_run is not None: + break + time.sleep(poll_interval_seconds) + + if final_run: + print("Final run:") + print(json.dumps(final_run.as_dict(), indent=2, default=str)) + print(f"The response Id is {final_run.response_id}") + elif run_was_triggered: + print("A routine run was observed, but no terminal run state was reached within the deadline.") + else: + print("No Teams-triggered run was observed within the deadline.") + except KeyboardInterrupt: + print("Interrupted by user; cleaning up routine before exiting.") + finally: + try: + project_client.beta.routines.delete(routine_name) + print("Routine deleted") + except ResourceNotFoundError: + pass + + +if __name__ == "__main__": + main() diff --git a/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolbox_with_reminder_preview.py b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolbox_with_reminder_preview.py new file mode 100644 index 000000000000..9720e0c81978 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolbox_with_reminder_preview.py @@ -0,0 +1,183 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + Create a Toolbox version that exposes a Reminder Preview tool over a + Foundry Toolbox MCP endpoint, then upload ``assets/toolbox-agent/`` as a + REMOTE_BUILD code asset for a Hosted Agent version. The sample waits for + the new version to become active, assigns Azure AI User RBAC to the hosted + agent identity on the Foundry account, temporarily routes the Hosted Agent + endpoint to that version, sends a reminder request through the Responses + API, queries routines to find the service-created one-shot routine, and + finally restores the previous endpoint and deletes the temporary agent + version and toolbox. + + The hosted agent must already exist; create it first with: + samples/hosted_agents/sample_create_hosted_agent_from_image.py + +USAGE: + python sample_toolbox_with_reminder_preview.py + + Before running the sample: + + pip install "azure-ai-projects>=2.3.0" azure-identity azure-mgmt-authorization azure-mgmt-resource python-dotenv + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - The Azure AI Project endpoint, as found in the + Overview page of your Microsoft Foundry portal. + 2) FOUNDRY_MODEL_NAME - The deployment name of the AI model. + 3) FOUNDRY_HOSTED_AGENT_NAME - Optional. The Hosted Agent name. Defaults to + `MyHostedAgent`. The Hosted Agent must already exist. + 4) AZURE_SUBSCRIPTION_ID - The Azure subscription ID containing the + Foundry project/account. This is used to assign Azure AI User RBAC to + the hosted agent identity. +""" + +import os +import time +from pathlib import Path + +from dotenv import load_dotenv + +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import DefaultAzureCredential + +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import ( + CodeConfiguration, + CodeDependencyResolution, + HostedAgentDefinition, + ProtocolVersionRecord, + ReminderPreviewToolboxTool, +) + +from hosted_agents_util import create_version_from_code +from rbac_util import ensure_agent_identity_rbac +from util import zip_directory + +load_dotenv() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +model_name = os.environ["FOUNDRY_MODEL_NAME"] +subscription_id = os.environ["AZURE_SUBSCRIPTION_ID"] +agent_name = os.environ.get("FOUNDRY_HOSTED_AGENT_NAME", "MyHostedAgent") + +_HOSTED_AGENT_SOURCE_DIR = Path(__file__).parent / "assets" / "toolbox-agent" + + +TOOLBOX_NAME = "toolbox_with_reminder_preview" + + +def list_routine_names(project_client: AIProjectClient) -> set[str]: + routines = list(project_client.beta.routines.list()) + return {routine.name for routine in routines if routine.name} + + +def main() -> None: + created_routine_names: set[str] = set() + with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, + ): + try: + project_client.toolboxes.delete(TOOLBOX_NAME) + except ResourceNotFoundError: + pass + + toolbox_version = project_client.toolboxes.create_version( + name=TOOLBOX_NAME, + description="Toolbox exposing a reminder preview tool.", + tools=[ + ReminderPreviewToolboxTool( + name="reminder", + description="Schedule a reminder to re-invoke the agent after a short delay.", + ), + ], + metadata={"enableVnextExperience": "true"}, + ) + print(f"Created toolbox: {toolbox_version.name} version={toolbox_version.version}") + + toolbox_mcp_url = f"{endpoint}/toolboxes/{TOOLBOX_NAME}/versions/{toolbox_version.version}/mcp?api-version=v1" + + zip_filename = "hosted-toolbox-mcp-reminder-preview-agent.zip" + _, _, zip_path = zip_directory(_HOSTED_AGENT_SOURCE_DIR, zip_filename) + try: + with ( + zip_path.open("rb") as code_stream, + create_version_from_code( + project_client=project_client, + agent_name=agent_name, + description="Hosted agent code for toolbox MCP reminder preview tool.", + definition=HostedAgentDefinition( + cpu="0.5", + memory="1Gi", + code_configuration=CodeConfiguration( + runtime="python_3_13", + entry_point=["python", "main.py"], + dependency_resolution=CodeDependencyResolution.REMOTE_BUILD, + ), + environment_variables={ + "FOUNDRY_PROJECT_ENDPOINT": endpoint, + "FOUNDRY_MODEL_NAME": model_name, + "MCP_SERVER_URL": toolbox_mcp_url, + }, + protocol_versions=[ + ProtocolVersionRecord(protocol="responses", version="2.0.0"), + ProtocolVersionRecord(protocol="invocations", version="2.0.0"), + ], + ), + code=code_stream, + ) as agent, + project_client.get_openai_client(agent_name=agent_name) as hosted_openai_client, + ): + + # toolbox requires the hosted agent identity to have Foundry User RBAC on the Foundry account, so assign it here + ensure_agent_identity_rbac( + agent=agent, + credential=credential, + subscription_id=subscription_id, + foundry_project_endpoint=endpoint, + ) + + routines_before = list_routine_names(project_client) + + user_input = "Use the reminder tool to remind me in 1 minute to check the coffee." + print(f"User: {user_input}") + response = hosted_openai_client.responses.create( + input=user_input, + ) + + response_text = response.output_text or "" + print("Response:") + print(response_text.encode("utf-8", errors="replace").decode("utf-8")) + + print("Routines after scheduling the reminder:") + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + routines_after = list_routine_names(project_client) + created_routine_names = routines_after - routines_before + if created_routine_names: + break + print("No new routine found yet; checking again shortly...") + time.sleep(5) + + if created_routine_names: + print("Retrieved new routine details:") + for routine_name in sorted(created_routine_names): + routine = project_client.beta.routines.get(routine_name) + print(f" - {routine.name} enabled={routine.enabled} description={routine.description!r}") + else: + print( + "No new routine was visible in project_client.beta.routines.list() after scheduling the reminder." + ) + finally: + project_client.toolboxes.delete(TOOLBOX_NAME) + print("Toolbox deleted") + + +if __name__ == "__main__": + main() 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 e4f029489353..6a57288454f6 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 @@ -6,30 +6,21 @@ """ DESCRIPTION: - Demonstrates deploying a code-based Hosted Agent that discovers and uses - skills from a Foundry Toolbox MCP endpoint via Agent Framework - `FoundryToolbox()`. - - The sample: - 1. Creates a shipping-cost skill. - 2. Creates a toolbox version that references the skill. - 3. Packages ``assets/toolbox-agent/`` source as a zip at runtime - (REMOTE_BUILD - the service resolves dependencies from requirements.txt). - 4. Deploys a new Hosted Agent version, forwarding the project endpoint, - model name, and toolbox MCP URL to the hosted code. - 5. Waits for the version to become active. - 6. Sends a query to the agent via the Responses API. - 7. Cleans up created resources (agent version, toolbox, and skill). - - The hosted agent must already exist; create it first with: - samples/hosted_agents/sample_create_hosted_agent_from_image.py + Create a shipping-cost Skill and a Toolbox version that exposes it over a + Foundry Toolbox MCP endpoint, then upload ``assets/toolbox-agent/`` as a + REMOTE_BUILD code asset for a Hosted Agent version. The sample waits for + the new version to become active, assigns Azure AI User RBAC to the hosted + agent identity on the Foundry account, temporarily routes the Hosted Agent + endpoint to that version, sends a query through the Responses API, and + finally restores the previous endpoint and deletes the temporary agent + version, toolbox, and skill. USAGE: python sample_toolbox_with_skill.py Before running the sample: - pip install "azure-ai-projects>=2.3.0" python-dotenv + pip install "azure-ai-projects>=2.3.0" azure-identity azure-mgmt-authorization azure-mgmt-resource python-dotenv Set these environment variables with your own values: 1) FOUNDRY_PROJECT_ENDPOINT - The Azure AI Project endpoint, as found in the @@ -38,16 +29,15 @@ the "Name" column in the "Models + endpoints" tab in your Foundry project. 3) FOUNDRY_HOSTED_AGENT_NAME - Optional. The Hosted Agent name. Defaults to `MyHostedAgent`. The Hosted Agent must already exist. + 4) AZURE_SUBSCRIPTION_ID - The Azure subscription ID containing the + Foundry project/account. This is used to assign Azure AI User RBAC to + the hosted agent identity. """ import os import sys from pathlib import Path -_SAMPLES_DIR = Path(__file__).resolve().parents[1] -if str(_SAMPLES_DIR) not in sys.path: - sys.path.insert(0, str(_SAMPLES_DIR)) - from dotenv import load_dotenv from azure.identity import DefaultAzureCredential @@ -60,6 +50,7 @@ ) from hosted_agents_util import create_version_from_code +from rbac_util import ensure_agent_identity_rbac from util import zip_directory from azure.core.exceptions import ResourceNotFoundError @@ -73,6 +64,7 @@ endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model_name = os.environ["FOUNDRY_MODEL_NAME"] +subscription_id = os.environ["AZURE_SUBSCRIPTION_ID"] agent_name = os.environ.get("FOUNDRY_HOSTED_AGENT_NAME", "MyHostedAgent") _HOSTED_AGENT_SOURCE_DIR = Path(__file__).parent / "assets" / "toolbox-agent" @@ -147,10 +139,18 @@ def main() -> None: protocol_versions=[ProtocolVersionRecord(protocol="responses", version="2.0.0")], ), code=code_stream, - ), + ) as agent, project_client.get_openai_client(agent_name=agent_name) as hosted_openai_client, ): + # toolbox requires the hosted agent identity to have Foundry User RBAC on the Foundry account, so assign it here + ensure_agent_identity_rbac( + agent=agent, + credential=credential, + subscription_id=subscription_id, + foundry_project_endpoint=endpoint, + ) + user_input = "Compute the shipping cost for a 3 kg package shipped domestically." print(f"User: {user_input}") response = hosted_openai_client.responses.create(input=user_input) 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 8d84a889a14c..24a9b8c707c6 100644 --- a/sdk/ai/azure-ai-projects/tests/samples/test_samples.py +++ b/sdk/ai/azure-ai-projects/tests/samples/test_samples.py @@ -270,13 +270,6 @@ def test_chat_completions_samples(self, sample_path: str, **kwargs) -> None: "ZIP_FILE_PATH": "tests/samples/assets/basic-agent.zip", }, ), - AdditionalSampleTestDetail( - test_id="sample_toolbox_with_skill", - sample_filename="sample_toolbox_with_skill.py", - env_vars={ - "ZIP_FILE_PATH": "tests/samples/assets/toolbox-agent.zip", - }, - ), AdditionalSampleTestDetail( test_id="sample_agent_user_identity_isolation", sample_filename="sample_agent_user_identity_isolation.py", @@ -293,7 +286,7 @@ def test_chat_completions_samples(self, sample_path: str, **kwargs) -> None: get_sample_paths( "hosted_agents", samples_to_skip=[ - "sample_toolbox_with_skill.py", # Specified through AdditionalSampleTestDetail + "sample_toolbox_with_skill.py", # Skip due to RBAC assignment that cannot be recorded "sample_create_hosted_agent_from_code.py", # Specified through AdditionalSampleTestDetail "sample_agent_user_identity_isolation.py", # Specified through AdditionalSampleTestDetail "sample_session_log_stream.py", # Specified through AdditionalSampleTestDetail @@ -303,6 +296,8 @@ def test_chat_completions_samples(self, sample_path: str, **kwargs) -> None: "sample_routines_with_schedule_trigger.py", # 500 "sample_routines_with_timer_trigger.py", # Timer is used causing request response not matched "sample_routines_with_github_issue_trigger.py", # Cannot run without interact on Github + "sample_routines_with_teams_message_trigger.py", # Cannot run without live Teams event + "sample_toolbox_with_reminder_preview.py", # Skip due to RBAC assignment that cannot be recorded ], ), )