Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion sdk/ai/azure-ai-projects/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ Breaking changes in beta classes:

### Sample updates

* Added Hosted Agent creation sample `sample_hosted_agent_create.py`, demonstrating hosted agent version creation and retrieval with `AIProjectClient`.
* Added Hosted Agent creation samples `sample_create_hosted_agent.py` and `sample_create_hosted_agent_async.py`, demonstrating hosted agent version creation and retrieval with `AIProjectClient`.
* Added Hosted Agent code-upload samples `sample_create_hosted_agent_from_code.py` and `sample_create_hosted_agent_from_code_async.py`, demonstrating uploading a code package (zip) as a new hosted agent version.
* The Hosted Agent creation sample also demonstrates assigning the hosted agent managed identity the Azure AI User RBAC role on the backing Azure AI account.
* Updated the other Hosted Agent samples to reuse an existing Hosted Agent as a prerequisite, instead of creating a new hosted agent version in each sample.
* Added Toolbox tool-search sample `sample_toolboxes_with_search_preview.py` and `sample_toolboxes_with_search_preview_async.py`, demonstrating creating a Toolbox version with `ToolboxSearchPreviewTool` and invoking `MCPTool`.
Expand Down
2 changes: 1 addition & 1 deletion sdk/ai/azure-ai-projects/assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "python",
"TagPrefix": "python/ai/azure-ai-projects",
"Tag": "python/ai/azure-ai-projects_078d4bf75b"
"Tag": "python/ai/azure-ai-projects_b8c168cd69"
}
18 changes: 11 additions & 7 deletions sdk/ai/azure-ai-projects/azure/ai/projects/_utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,22 @@ def prepare_multipart_form_data(
body: Mapping[str, Any], multipart_fields: list[str], data_fields: list[str]
) -> list[FileType]:
files: list[FileType] = []

# Append data fields first so they appear before file parts in the encoded
# multipart body. Some streaming server-side parsers (e.g. the Foundry
# hosted-agents `create_agent_version_from_code` endpoint) require small
# JSON metadata parts to precede large binary file parts; otherwise they
# report the metadata part as missing.
for data_field in data_fields:
data_entry = body.get(data_field)
if data_entry:
files.append((data_field, str(serialize_multipart_data_entry(data_entry))))

for multipart_field in multipart_fields:
multipart_entry = body.get(multipart_field)
if isinstance(multipart_entry, list):
files.extend([(multipart_field, e) for e in multipart_entry])
elif multipart_entry:
files.append((multipart_field, multipart_entry))

# if files is empty, sdk core library can't handle multipart/form-data correctly, so
# we put data fields into files with filename as None to avoid that scenario.
for data_field in data_fields:
data_entry = body.get(data_field)
if data_entry:
files.append((data_field, str(serialize_multipart_data_entry(data_entry))))

return files
9 changes: 9 additions & 0 deletions sdk/ai/azure-ai-projects/post-emitter-fixes.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ REM Fix Sphinx docutils warnings in get_session_log_stream docstrings (sync + as
REM The emitter wraps bullet/code-block lines with insufficient indentation.
powershell -Command "$files='azure\ai\projects\operations\_operations.py','azure\ai\projects\aio\operations\_operations.py'; foreach ($f in $files) { $c=Get-Content $f -Raw; $c=$c -replace 'schema\r?\n\s+is not contractual and may include additional keys or change format\r?\n\s+over time [^\r\n]*clients should treat it as an opaque string\)', 'schema is not contractual and may include additional keys or change format over time; clients should treat it as an opaque string)'; $c=$c -replace '(message\":\"Starting)\r?\n\s+(FoundryCBAgent server on port 8088\"})', '$1 $2'; $c=$c -replace '(message\":\"INFO: Application)\r?\n\s+(startup complete\.\"})', '$1 $2'; $c=$c -replace '(message\":\"Successfully)\r?\n\s+(connected to container\"})', '$1 $2'; $c=$c -replace '(message\":\"No logs since)\r?\n\s+(last 60 seconds\"})', '$1 $2'; Set-Content $f $c -NoNewline }"

REM Reorder loops in `prepare_multipart_form_data` (azure\ai\projects\_utils\utils.py).
REM The emitter generates the multipart-file loop before the data-field loop, so JSON
REM metadata parts end up after large binary file parts in the encoded body. Some
REM streaming server-side parsers (e.g. the Foundry hosted-agents
REM `create_agent_version_from_code` endpoint) require the small JSON metadata parts
REM to precede the binary file parts; otherwise they report the metadata part as missing.
REM This rewrite swaps the two loops so data fields are appended first.
powershell -Command "$f='azure\ai\projects\_utils\utils.py'; $c=Get-Content $f -Raw; if ($c -notmatch 'Append data fields first') { $pattern='(?s) files: list\[FileType\] = \[\]\r?\n.*? return files'; $new=(@(' files: list[FileType] = []','',' # Append data fields first so they appear before file parts in the encoded',' # multipart body. Some streaming server-side parsers (e.g. the Foundry',' # hosted-agents `create_agent_version_from_code` endpoint) require small',' # JSON metadata parts to precede large binary file parts; otherwise they',' # report the metadata part as missing.',' for data_field in data_fields:',' data_entry = body.get(data_field)',' if data_entry:',' files.append((data_field, str(serialize_multipart_data_entry(data_entry))))','',' for multipart_field in multipart_fields:',' multipart_entry = body.get(multipart_field)',' if isinstance(multipart_entry, list):',' files.extend([(multipart_field, e) for e in multipart_entry])',' elif multipart_entry:',' files.append((multipart_field, multipart_entry))','',' return files') -join [Environment]::NewLine); $c=[regex]::Replace($c, $pattern, $new); Set-Content $f $c -NoNewline }"

REM Finishing by running 'black' tool to format code.
pip install black
black --config ../../../eng/black-pyproject.toml . || echo black not found, skipping formatting.
Expand Down
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
azure-ai-agentserver-core==2.0.0b3
azure-ai-agentserver-invocations==1.0.0b3
azure-ai-agentserver-responses==1.0.0b5

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,47 +1,99 @@
import asyncio
import logging
from typing import Optional
import hashlib
import time
from pathlib import Path
from typing import Tuple

from azure.ai.projects import AIProjectClient
from azure.ai.projects.aio import AIProjectClient as AsyncAIProjectClient
from azure.ai.projects.models import (
AgentVersionDetails,
CodeDependencyResolution,
)

_ASSETS_DIR = Path(__file__).parent / "assets"


def select_echo_agent_code_zip(
use_remote_build: bool,
) -> Tuple[CodeDependencyResolution, str, bytes, str]:
"""Pick the dependency-resolution mode and matching echo-agent zip, and load it.

When ``use_remote_build`` is ``True``, returns REMOTE_BUILD with
``assets/echo-agent.zip``; otherwise BUNDLED with
``assets/echo-agent-prebuilt.zip``.

Reads the zip bytes, computes its SHA-256, and prints a one-line summary.

Returns ``(dependency_resolution, zip_filename, zip_bytes, zip_sha256)``.
"""
dependency_resolution = (
CodeDependencyResolution.REMOTE_BUILD if use_remote_build else CodeDependencyResolution.BUNDLED
)
zip_filename = "echo-agent.zip" if use_remote_build else "echo-agent-prebuilt.zip"
zip_path = _ASSETS_DIR / zip_filename
zip_bytes = zip_path.read_bytes()
zip_sha256 = hashlib.sha256(zip_bytes).hexdigest()
print(
f"Loaded code zip from {zip_path} (dependency_resolution={dependency_resolution.value}): "
f"{len(zip_bytes)} bytes, sha256={zip_sha256}"
)
return dependency_resolution, zip_filename, zip_bytes, zip_sha256


def wait_for_agent_version_active(
project_client: AIProjectClient,
agent_name: str,
agent_version: str,
*,
max_attempts: int = 60,
poll_interval_seconds: int = 10,
) -> None:
"""Poll until the version becomes ``active``; raise on ``failed`` or timeout."""
print("Waiting for agent version to become active...")

for attempt in range(max_attempts):
time.sleep(poll_interval_seconds)
version_details = project_client.agents.get_version(agent_name=agent_name, agent_version=agent_version)
status = version_details["status"]

print(f"Agent version status: {status} (attempt {attempt + 1}/{max_attempts})")

if status == "active":
print("Agent version is now active")
return

if status == "failed":
raise RuntimeError(f"Agent version provisioning failed: {dict(version_details)}")

raise RuntimeError("Timed out waiting for agent version to become active")


async def wait_for_agent_version_active_async(
project_client: AsyncAIProjectClient,
agent_name: str,
agent_version: str,
*,
logger: Optional[logging.Logger] = None,
max_attempts: int = 60,
poll_interval_seconds: int = 10,
) -> None:
if logger:
logger.info("Waiting for agent version to become active...")
"""Async variant of :func:`wait_for_agent_version_active`."""
print("Waiting for agent version to become active...")

for attempt in range(max_attempts):
await asyncio.sleep(poll_interval_seconds)
version_details = await project_client.agents.get_version(agent_name=agent_name, agent_version=agent_version)
status = version_details["status"]

if logger:
logger.debug(f"Agent version status: {status} (attempt {attempt + 1}/{max_attempts})")
print(f"Agent version status: {status} (attempt {attempt + 1})")
print(f"Agent version status: {status} (attempt {attempt + 1}/{max_attempts})")

if status == "active":
if logger:
logger.info("Agent version is now active")
print("Agent version is now active")
return

if status == "failed":
if logger:
logger.error(f"Agent version provisioning failed: {dict(version_details)}")
raise RuntimeError(f"Agent version provisioning failed: {dict(version_details)}")

if logger:
logger.error("Timed out waiting for agent version to become active")
raise RuntimeError("Timed out waiting for agent version to become active")


Expand Down
124 changes: 122 additions & 2 deletions sdk/ai/azure-ai-projects/samples/hosted_agents/rbac_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from urllib.parse import urlparse

from azure.core.credentials import TokenCredential
from azure.core.exceptions import HttpResponseError, ResourceExistsError, ResourceNotFoundError
from azure.core.credentials_async import AsyncTokenCredential
from azure.core.exceptions import ResourceNotFoundError
from azure.mgmt.authorization import AuthorizationManagementClient, models as authorization_models
from azure.mgmt.authorization.aio import AuthorizationManagementClient as AsyncAuthorizationManagementClient
from azure.mgmt.resource import ResourceManagementClient

from azure.mgmt.resource.resources.aio import ResourceManagementClient as AsyncResourceManagementClient
from azure.ai.projects.models import AgentVersionDetails

AZURE_AI_USER_ROLE_DEFINITION_GUID = "53ca6127-db72-4b80-b1b0-d745d6d5456d"
Expand Down Expand Up @@ -130,3 +132,121 @@ def ensure_agent_identity_rbac(
subscription_id=subscription_id,
role_id=AZURE_AI_USER_ROLE_DEFINITION_GUID,
)


async def _resolve_ai_account_resource_id_async(
credential: AsyncTokenCredential,
account_name: str,
project_name: str,
subscription_id: str,
) -> str:
async with AsyncResourceManagementClient(credential, subscription_id) as resource_client:
project_id_segment = f"/accounts/{account_name}/projects/{project_name}".lower()
matching_projects = []
async for resource in resource_client.resources.list(
filter="resourceType eq 'Microsoft.CognitiveServices/accounts/projects'"
):
if resource.id and project_id_segment in resource.id.lower():
matching_projects.append(resource)
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_matches = []
async for resource in resource_client.resources.list_by_resource_group(
resource_group_name=resource_group_name,
filter="resourceType eq 'Microsoft.CognitiveServices/accounts'",
):
if resource.name == account_name and resource.id:
account_matches.append(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]


async def _ensure_agent_identity_rbac_with_role_id_async(
credential: AsyncTokenCredential,
principal_id: str,
scope_resource_id: str,
subscription_id: str,
role_id: str,
) -> tuple[bool, str]:
async with AsyncAuthorizationManagementClient(credential, subscription_id) as authorization_client:
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:
await authorization_client.role_assignments.get(scope_resource_id, role_assignment_name)
print(f"Azure AI 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)

await authorization_client.role_assignments.create(scope_resource_id, role_assignment_name, parameters)
print(f"Assigned Azure AI User role to principal {principal_id} at scope {scope_resource_id}.")
return True, role_assignment_name


async def ensure_agent_identity_rbac_async(
agent: AgentVersionDetails,
credential: AsyncTokenCredential,
subscription_id: str,
foundry_project_endpoint: str,
) -> None:
"""Async variant of :func:`ensure_agent_identity_rbac`.

:param agent: Agent version details containing ``instance_identity``.
:type agent: ~azure.ai.projects.models.AgentVersionDetails
:param credential: Async credential used for Azure Resource Manager authorization calls.
:type credential: ~azure.core.credentials_async.AsyncTokenCredential
: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://<account>.services.ai.azure.com/api/projects/<project-name>``.
: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 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 = await _resolve_ai_account_resource_id_async(
credential, account_name, project_name, subscription_id
)

await _ensure_agent_identity_rbac_with_role_id_async(
credential=credential,
principal_id=principal_id,
scope_resource_id=scope_resource_id,
subscription_id=subscription_id,
role_id=AZURE_AI_USER_ROLE_DEFINITION_GUID,
)
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
page of your Microsoft Foundry portal.
2) FOUNDRY_HOSTED_AGENT_NAME - The name of an existing Hosted Agent.
If you don't have a Hosted Agent, run `sample_hosted_agent_create.py` first
to create one as a prerequisite.
If you don't have a Hosted Agent, run `sample_create_hosted_agent.py` or
`sample_create_hosted_agent_from_code.py` first to create one as a prerequisite.
"""

import os
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
page of your Microsoft Foundry portal.
2) FOUNDRY_HOSTED_AGENT_NAME - The name of an existing Hosted Agent.
If you don't have a Hosted Agent, run `sample_hosted_agent_create.py` first
to create one as a prerequisite.
If you don't have a Hosted Agent, run `sample_create_hosted_agent_async.py` or
`sample_create_hosted_agent_from_code_async.py` first to create one as a prerequisite.
"""

import asyncio
Expand Down
Loading