chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._agent import FoundryAgent, FoundryAgentOptions, RawFoundryAgent, RawFoundryAgentChatClient
|
||||
from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient
|
||||
from ._embedding_client import (
|
||||
FoundryEmbeddingClient,
|
||||
FoundryEmbeddingOptions,
|
||||
FoundryEmbeddingSettings,
|
||||
RawFoundryEmbeddingClient,
|
||||
)
|
||||
from ._foundry_evals import (
|
||||
FoundryEvals,
|
||||
GeneratedEvaluatorRef,
|
||||
evaluate_foundry_target,
|
||||
evaluate_traces,
|
||||
)
|
||||
from ._memory_provider import FoundryMemoryProvider
|
||||
from ._to_prompt_agent import to_prompt_agent
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"FoundryAgent",
|
||||
"FoundryAgentOptions",
|
||||
"FoundryChatClient",
|
||||
"FoundryChatOptions",
|
||||
"FoundryEmbeddingClient",
|
||||
"FoundryEmbeddingOptions",
|
||||
"FoundryEmbeddingSettings",
|
||||
"FoundryEvals",
|
||||
"FoundryMemoryProvider",
|
||||
"GeneratedEvaluatorRef",
|
||||
"RawFoundryAgent",
|
||||
"RawFoundryAgentChatClient",
|
||||
"RawFoundryChatClient",
|
||||
"RawFoundryEmbeddingClient",
|
||||
"__version__",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
"to_prompt_agent",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,396 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from contextlib import suppress
|
||||
from typing import Any, ClassVar, Generic, TypedDict
|
||||
|
||||
from agent_framework import (
|
||||
BaseEmbeddingClient,
|
||||
Content,
|
||||
Embedding,
|
||||
EmbeddingGenerationOptions,
|
||||
GeneratedEmbeddings,
|
||||
UsageDetails,
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework.observability import EmbeddingTelemetryLayer
|
||||
from azure.ai.inference.aio import EmbeddingsClient, ImageEmbeddingsClient
|
||||
from azure.ai.inference.models import ImageEmbeddingInput
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypeVar # pragma: no cover
|
||||
|
||||
|
||||
logger = logging.getLogger("agent_framework.foundry")
|
||||
|
||||
_IMAGE_MEDIA_PREFIXES = ("image/",)
|
||||
|
||||
|
||||
class FoundryEmbeddingOptions(EmbeddingGenerationOptions, total=False):
|
||||
"""Foundry inference-specific embedding options.
|
||||
|
||||
Extends ``EmbeddingGenerationOptions`` with Foundry inference-specific fields.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_foundry import FoundryEmbeddingOptions
|
||||
|
||||
options: FoundryEmbeddingOptions = {
|
||||
"model": "text-embedding-3-small",
|
||||
"dimensions": 1536,
|
||||
"input_type": "document",
|
||||
"encoding_format": "float",
|
||||
}
|
||||
"""
|
||||
|
||||
input_type: str
|
||||
"""Input type hint for the model. Common values: ``"text"``, ``"query"``, ``"document"``."""
|
||||
|
||||
image_model: str
|
||||
"""Override model for image embeddings. Falls back to the client's ``image_model``."""
|
||||
|
||||
encoding_format: str
|
||||
"""Output encoding format.
|
||||
|
||||
Common values: ``"float"``, ``"base64"``, ``"int8"``, ``"uint8"``,
|
||||
``"binary"``, ``"ubinary"``.
|
||||
"""
|
||||
|
||||
extra_parameters: dict[str, Any]
|
||||
"""Additional model-specific parameters passed directly to the API."""
|
||||
|
||||
|
||||
FoundryEmbeddingOptionsT = TypeVar(
|
||||
"FoundryEmbeddingOptionsT",
|
||||
bound=TypedDict, # type: ignore[valid-type]
|
||||
default="FoundryEmbeddingOptions",
|
||||
covariant=True,
|
||||
)
|
||||
|
||||
|
||||
class FoundryEmbeddingSettings(TypedDict, total=False):
|
||||
"""Foundry inference embedding settings."""
|
||||
|
||||
models_endpoint: str | None
|
||||
models_api_key: str | None
|
||||
embedding_model: str | None
|
||||
image_embedding_model: str | None
|
||||
|
||||
|
||||
class RawFoundryEmbeddingClient(
|
||||
BaseEmbeddingClient[Content | str, list[float], FoundryEmbeddingOptionsT],
|
||||
Generic[FoundryEmbeddingOptionsT],
|
||||
):
|
||||
"""Raw Foundry embedding client without telemetry.
|
||||
|
||||
Accepts both text (``str``) and image (``Content``) inputs. Text and image
|
||||
inputs within a single batch are separated and dispatched to
|
||||
``EmbeddingsClient`` and ``ImageEmbeddingsClient`` respectively. Results
|
||||
are reassembled in the original input order.
|
||||
|
||||
Keyword Args:
|
||||
model: The text embedding model (e.g. "text-embedding-3-small").
|
||||
Can also be set via environment variable FOUNDRY_EMBEDDING_MODEL.
|
||||
image_model: The image embedding model (e.g. "Cohere-embed-v3-english").
|
||||
Can also be set via environment variable FOUNDRY_IMAGE_EMBEDDING_MODEL.
|
||||
Falls back to ``model`` if not provided.
|
||||
endpoint: The Foundry inference endpoint URL.
|
||||
Can also be set via environment variable FOUNDRY_MODELS_ENDPOINT.
|
||||
api_key: API key for authentication.
|
||||
Can also be set via environment variable FOUNDRY_MODELS_API_KEY.
|
||||
text_client: Optional pre-configured ``EmbeddingsClient``.
|
||||
image_client: Optional pre-configured ``ImageEmbeddingsClient``.
|
||||
credential: Optional ``AzureKeyCredential`` or token credential. If not provided,
|
||||
one is created from ``api_key``.
|
||||
env_file_path: Path to .env file for settings.
|
||||
env_file_encoding: Encoding for .env file.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str | None = None,
|
||||
image_model: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
api_key: str | None = None,
|
||||
text_client: EmbeddingsClient | None = None,
|
||||
image_client: ImageEmbeddingsClient | None = None,
|
||||
credential: AzureKeyCredential | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a raw Foundry embedding client."""
|
||||
settings = load_settings(
|
||||
FoundryEmbeddingSettings,
|
||||
env_prefix="FOUNDRY_",
|
||||
required_fields=["models_endpoint", "embedding_model"],
|
||||
models_endpoint=endpoint,
|
||||
models_api_key=api_key,
|
||||
embedding_model=model,
|
||||
image_embedding_model=image_model,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
|
||||
self.model = settings["embedding_model"] # type: ignore[reportTypedDictNotRequiredAccess]
|
||||
self.image_model: str = settings.get("image_embedding_model") or self.model # type: ignore[assignment]
|
||||
resolved_endpoint = settings["models_endpoint"] # type: ignore[reportTypedDictNotRequiredAccess]
|
||||
|
||||
if credential is None and settings.get("models_api_key"):
|
||||
credential = AzureKeyCredential(settings["models_api_key"]) # type: ignore[arg-type]
|
||||
|
||||
if credential is None and text_client is None and image_client is None:
|
||||
raise ValueError("Either 'api_key', 'credential', or pre-configured client(s) must be provided.")
|
||||
|
||||
self._text_client = text_client or EmbeddingsClient(
|
||||
endpoint=resolved_endpoint, # type: ignore[arg-type]
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
)
|
||||
self._image_client = image_client or ImageEmbeddingsClient(
|
||||
endpoint=resolved_endpoint, # type: ignore[arg-type]
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
)
|
||||
self._endpoint = resolved_endpoint
|
||||
super().__init__(additional_properties=additional_properties)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying SDK clients and release resources."""
|
||||
with suppress(Exception):
|
||||
await self._text_client.close()
|
||||
with suppress(Exception):
|
||||
await self._image_client.close()
|
||||
|
||||
async def __aenter__(self) -> RawFoundryEmbeddingClient[FoundryEmbeddingOptionsT]:
|
||||
"""Enter the async context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
"""Exit the async context manager and close clients."""
|
||||
await self.close()
|
||||
|
||||
def service_url(self) -> str:
|
||||
"""Get the URL of the service."""
|
||||
return self._endpoint or ""
|
||||
|
||||
async def get_embeddings(
|
||||
self,
|
||||
values: Sequence[Content | str],
|
||||
*,
|
||||
options: FoundryEmbeddingOptionsT | None = None,
|
||||
) -> GeneratedEmbeddings[list[float], FoundryEmbeddingOptionsT]:
|
||||
"""Generate embeddings for text and/or image inputs.
|
||||
|
||||
Text inputs (``str`` or ``Content`` with ``type="text"``) are sent to the
|
||||
text embeddings endpoint. Image inputs (``Content`` with an image
|
||||
``media_type``) are sent to the image embeddings endpoint. Results are
|
||||
returned in the same order as the input.
|
||||
|
||||
Args:
|
||||
values: A sequence of text strings or ``Content`` instances.
|
||||
options: Optional embedding generation options.
|
||||
|
||||
Returns:
|
||||
Generated embeddings with usage metadata.
|
||||
|
||||
Raises:
|
||||
ValueError: If model is not provided or an unsupported content type is encountered.
|
||||
"""
|
||||
if not values:
|
||||
return GeneratedEmbeddings([], options=options)
|
||||
|
||||
opts: dict[str, Any] = dict(options) if options else {}
|
||||
|
||||
# Separate text and image inputs, tracking original indices.
|
||||
text_items: list[tuple[int, str]] = []
|
||||
image_items: list[tuple[int, ImageEmbeddingInput]] = []
|
||||
|
||||
for idx, value in enumerate(values):
|
||||
if isinstance(value, str):
|
||||
text_items.append((idx, value))
|
||||
elif isinstance(value, Content):
|
||||
if value.type == "text" and value.text is not None:
|
||||
text_items.append((idx, value.text))
|
||||
elif (
|
||||
value.type in ("data", "uri")
|
||||
and value.media_type
|
||||
and value.media_type.startswith(_IMAGE_MEDIA_PREFIXES[0])
|
||||
):
|
||||
if not value.uri:
|
||||
raise ValueError(f"Image Content at index {idx} has no URI.")
|
||||
image_input = ImageEmbeddingInput(image=value.uri, text=value.text)
|
||||
image_items.append((idx, image_input))
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported Content type '{value.type}' with media_type "
|
||||
f"'{value.media_type}' at index {idx}. Expected text content or "
|
||||
f"image content (media_type starting with 'image/')."
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported input type {type(value).__name__} at index {idx}.")
|
||||
|
||||
# Build shared API kwargs (without model, which differs per client).
|
||||
common_kwargs: dict[str, Any] = {}
|
||||
if dimensions := opts.get("dimensions"):
|
||||
common_kwargs["dimensions"] = dimensions
|
||||
if encoding_format := opts.get("encoding_format"):
|
||||
common_kwargs["encoding_format"] = encoding_format
|
||||
if input_type := opts.get("input_type"):
|
||||
common_kwargs["input_type"] = input_type
|
||||
if extra_parameters := opts.get("extra_parameters"):
|
||||
common_kwargs["model_extras"] = extra_parameters
|
||||
|
||||
# Allocate results array.
|
||||
embeddings: list[Embedding[list[float]] | None] = [None] * len(values)
|
||||
usage_details: UsageDetails = {}
|
||||
|
||||
# Embed text inputs.
|
||||
if text_items:
|
||||
if not (text_model := opts.get("model") or self.model):
|
||||
raise ValueError("A model is required, either in the client or options, for text inputs.")
|
||||
text_inputs = [t for _, t in text_items]
|
||||
response = await self._text_client.embed(
|
||||
input=text_inputs,
|
||||
model=text_model,
|
||||
**common_kwargs,
|
||||
)
|
||||
for i, item in enumerate(response.data):
|
||||
original_idx = text_items[i][0]
|
||||
vector: list[float] = [float(v) for v in item.embedding]
|
||||
embeddings[original_idx] = Embedding(
|
||||
vector=vector,
|
||||
dimensions=len(vector),
|
||||
model=response.model or text_model,
|
||||
)
|
||||
if response.usage:
|
||||
usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + (
|
||||
response.usage.prompt_tokens or 0
|
||||
)
|
||||
usage_details["output_token_count"] = (usage_details.get("output_token_count") or 0) + (
|
||||
getattr(response.usage, "completion_tokens", 0) or 0
|
||||
)
|
||||
|
||||
# Embed image inputs.
|
||||
if image_items:
|
||||
if not (image_model := opts.get("image_model") or self.image_model):
|
||||
raise ValueError("An image_model is required, either in the client or options, for image inputs.")
|
||||
image_inputs = [img for _, img in image_items]
|
||||
response = await self._image_client.embed(
|
||||
input=image_inputs,
|
||||
model=image_model,
|
||||
**common_kwargs,
|
||||
)
|
||||
for i, item in enumerate(response.data):
|
||||
original_idx = image_items[i][0]
|
||||
image_vector: list[float] = [float(v) for v in item.embedding]
|
||||
embeddings[original_idx] = Embedding(
|
||||
vector=image_vector,
|
||||
dimensions=len(image_vector),
|
||||
model=response.model or image_model,
|
||||
)
|
||||
if response.usage:
|
||||
usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + (
|
||||
response.usage.prompt_tokens or 0
|
||||
)
|
||||
usage_details["output_token_count"] = (usage_details.get("output_token_count") or 0) + (
|
||||
getattr(response.usage, "completion_tokens", 0) or 0
|
||||
)
|
||||
return GeneratedEmbeddings(
|
||||
[embedding for embedding in embeddings if embedding is not None],
|
||||
options=options,
|
||||
usage=usage_details,
|
||||
)
|
||||
|
||||
|
||||
class FoundryEmbeddingClient(
|
||||
EmbeddingTelemetryLayer[Content | str, list[float], FoundryEmbeddingOptionsT],
|
||||
RawFoundryEmbeddingClient[FoundryEmbeddingOptionsT],
|
||||
Generic[FoundryEmbeddingOptionsT],
|
||||
):
|
||||
"""Foundry embedding client with telemetry support.
|
||||
|
||||
Supports both text and image inputs in a single client. Pass plain strings
|
||||
or ``Content`` instances created with ``Content.from_text()`` or
|
||||
``Content.from_data()``.
|
||||
|
||||
Keyword Args:
|
||||
model: The text embedding model (e.g. "text-embedding-3-small").
|
||||
Can also be set via environment variable FOUNDRY_EMBEDDING_MODEL.
|
||||
image_model: The image embedding model
|
||||
(e.g. "Cohere-embed-v3-english"). Can also be set via environment variable
|
||||
FOUNDRY_IMAGE_EMBEDDING_MODEL. Falls back to ``model``.
|
||||
endpoint: The Foundry inference endpoint URL.
|
||||
Can also be set via environment variable FOUNDRY_MODELS_ENDPOINT.
|
||||
api_key: API key for authentication.
|
||||
Can also be set via environment variable FOUNDRY_MODELS_API_KEY.
|
||||
text_client: Optional pre-configured ``EmbeddingsClient``.
|
||||
image_client: Optional pre-configured ``ImageEmbeddingsClient``.
|
||||
credential: Optional ``AzureKeyCredential`` or token credential.
|
||||
otel_provider_name: Override for the OpenTelemetry provider name.
|
||||
env_file_path: Path to .env file for settings.
|
||||
env_file_encoding: Encoding for .env file.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework_foundry import FoundryEmbeddingClient
|
||||
|
||||
# Using environment variables
|
||||
# Set FOUNDRY_MODELS_ENDPOINT=https://your-endpoint.inference.ai.azure.com
|
||||
# Set FOUNDRY_MODELS_API_KEY=your-key
|
||||
# Set FOUNDRY_EMBEDDING_MODEL=text-embedding-3-small
|
||||
# Set FOUNDRY_IMAGE_EMBEDDING_MODEL=Cohere-embed-v3-english
|
||||
client = FoundryEmbeddingClient()
|
||||
|
||||
# Text embeddings
|
||||
result = await client.get_embeddings(["Hello, world!"])
|
||||
|
||||
# Image embeddings
|
||||
from agent_framework import Content
|
||||
|
||||
image = Content.from_data(data=image_bytes, media_type="image/png")
|
||||
result = await client.get_embeddings([image])
|
||||
|
||||
# Mixed text and image
|
||||
result = await client.get_embeddings(["hello", image])
|
||||
"""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.inference"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str | None = None,
|
||||
image_model: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
api_key: str | None = None,
|
||||
text_client: EmbeddingsClient | None = None,
|
||||
image_client: ImageEmbeddingsClient | None = None,
|
||||
credential: AzureKeyCredential | None = None,
|
||||
otel_provider_name: str | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a Foundry embedding client."""
|
||||
super().__init__(
|
||||
model=model,
|
||||
image_model=image_model,
|
||||
endpoint=endpoint,
|
||||
api_key=api_key,
|
||||
text_client=text_client,
|
||||
image_client=image_client,
|
||||
credential=credential,
|
||||
additional_properties=additional_properties,
|
||||
otel_provider_name=otel_provider_name,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Foundry Memory Context Provider using ContextProvider.
|
||||
|
||||
This module provides ``FoundryMemoryProvider``, built on
|
||||
:class:`ContextProvider`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from agent_framework import (
|
||||
AgentSession,
|
||||
ContextProvider,
|
||||
Message,
|
||||
SessionContext,
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from openai.types.responses import ResponseInputItemParam
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self, TypedDict # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self, TypedDict # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import SupportsAgentRun
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
|
||||
|
||||
|
||||
class FoundryProjectSettings(TypedDict, total=False):
|
||||
"""Foundry project settings loaded from FOUNDRY_ environment variables."""
|
||||
|
||||
project_endpoint: str | None
|
||||
|
||||
|
||||
class FoundryMemoryProvider(ContextProvider):
|
||||
"""Foundry Memory context provider using the new ContextProvider hooks pattern.
|
||||
|
||||
Integrates Azure AI Foundry Memory Store for persistent semantic memory,
|
||||
searching and storing memories via the Azure AI Projects SDK.
|
||||
|
||||
Args:
|
||||
source_id: Unique identifier for this provider instance.
|
||||
project_client: Azure AI Project client for memory operations.
|
||||
memory_store_name: The name of the memory store to use.
|
||||
scope: The namespace that logically groups and isolates memories (e.g., user ID).
|
||||
context_prompt: The prompt to prepend to retrieved memories.
|
||||
update_delay: Timeout period before processing memory update in seconds.
|
||||
Defaults to 300 (5 minutes). Set to 0 to immediately trigger updates.
|
||||
"""
|
||||
|
||||
DEFAULT_SOURCE_ID: ClassVar[str] = "foundry_memory"
|
||||
DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = DEFAULT_SOURCE_ID,
|
||||
*,
|
||||
project_client: AIProjectClient | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
memory_store_name: str,
|
||||
scope: str | None = None,
|
||||
context_prompt: str | None = None,
|
||||
update_delay: int = 300,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Foundry Memory context provider.
|
||||
|
||||
Args:
|
||||
source_id: Unique identifier for this provider instance.
|
||||
project_client: Azure AI Project client for memory operations.
|
||||
project_endpoint: Foundry project endpoint URL. Used when project_client is not provided.
|
||||
credential: Azure credential for authentication. Accepts a TokenCredential,
|
||||
AsyncTokenCredential, or a callable token provider.
|
||||
Required when project_client is not provided.
|
||||
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
|
||||
memory_store_name: The name of the memory store to use.
|
||||
scope: The namespace that logically groups and isolates memories (e.g., user ID).
|
||||
If None, `session_id` will be used.
|
||||
context_prompt: The prompt to prepend to retrieved memories.
|
||||
update_delay: Timeout period before processing memory update in seconds.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
foundry_settings = load_settings(
|
||||
FoundryProjectSettings,
|
||||
env_prefix="FOUNDRY_",
|
||||
project_endpoint=project_endpoint,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
|
||||
if project_client is None:
|
||||
resolved_endpoint = foundry_settings.get("project_endpoint")
|
||||
if not resolved_endpoint:
|
||||
raise ValueError(
|
||||
"Foundry project endpoint is required. Set via 'project_endpoint' parameter "
|
||||
"or 'FOUNDRY_PROJECT_ENDPOINT' environment variable."
|
||||
)
|
||||
if not credential:
|
||||
raise ValueError("Azure credential is required when project_client is not provided.")
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": resolved_endpoint,
|
||||
"credential": credential,
|
||||
"user_agent": get_user_agent(),
|
||||
}
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
|
||||
if not memory_store_name:
|
||||
raise ValueError("memory_store_name is required")
|
||||
if not scope:
|
||||
raise ValueError("scope is required")
|
||||
|
||||
self.project_client = project_client
|
||||
self.memory_store_name = memory_store_name
|
||||
self.scope = scope
|
||||
self.context_prompt = context_prompt or self.DEFAULT_CONTEXT_PROMPT
|
||||
self.update_delay = update_delay
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Async context manager entry."""
|
||||
if self.project_client and isinstance(self.project_client, AbstractAsyncContextManager):
|
||||
await self.project_client.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
||||
"""Async context manager exit."""
|
||||
if self.project_client and isinstance(self.project_client, AbstractAsyncContextManager):
|
||||
await self.project_client.__aexit__(exc_type, exc_val, exc_tb)
|
||||
|
||||
# -- Hooks pattern ---------------------------------------------------------
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: SupportsAgentRun,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Search Foundry Memory for relevant memories and add to the session context.
|
||||
|
||||
This method:
|
||||
1. Retrieves static memories (user profile) on first call per session
|
||||
2. Searches for contextual memories based on input messages
|
||||
3. Combines and injects memories into the context
|
||||
"""
|
||||
# On first run, retrieve static memories (user profile memories)
|
||||
if not state.get("initialized"):
|
||||
try:
|
||||
static_search_result = await self.project_client.beta.memory_stores.search_memories(
|
||||
name=self.memory_store_name,
|
||||
scope=self.scope or context.session_id, # type: ignore[arg-type]
|
||||
)
|
||||
static_memories = [{"content": memory.memory_item.content} for memory in static_search_result.memories]
|
||||
state["static_memories"] = static_memories
|
||||
except Exception as e:
|
||||
# Log but don't fail - memory retrieval is non-critical
|
||||
logger.warning(f"Failed to retrieve static memories: {e}")
|
||||
state["static_memories"] = []
|
||||
finally:
|
||||
# Mark as initialized regardless of success to avoid repeated attempts
|
||||
state["initialized"] = True
|
||||
|
||||
# Search for contextual memories based on input messages
|
||||
# Check if there are any non-empty input messages
|
||||
has_input = any(msg and msg.text and msg.text.strip() for msg in context.input_messages)
|
||||
if not has_input:
|
||||
return
|
||||
|
||||
# Convert input messages to memory search item format
|
||||
items: list[ResponseInputItemParam] = [
|
||||
{"type": "message", "role": "user", "content": msg.text}
|
||||
for msg in context.input_messages
|
||||
if msg and msg.text and msg.text.strip()
|
||||
]
|
||||
|
||||
try:
|
||||
search_result = await self.project_client.beta.memory_stores.search_memories(
|
||||
name=self.memory_store_name,
|
||||
scope=self.scope or context.session_id, # type: ignore[arg-type]
|
||||
items=items,
|
||||
previous_search_id=state.get("previous_search_id"),
|
||||
)
|
||||
|
||||
# Extract search_id for next incremental search
|
||||
if search_result.memories:
|
||||
state["previous_search_id"] = search_result.search_id
|
||||
|
||||
# Combine static and contextual memories
|
||||
contextual_memories = [{"content": memory.memory_item.content} for memory in search_result.memories]
|
||||
|
||||
all_memories = state.get("static_memories", []) + contextual_memories
|
||||
|
||||
# Inject memories into context
|
||||
if all_memories:
|
||||
line_separated_memories = "\n".join(
|
||||
str(memory.get("content", "")) for memory in all_memories if memory.get("content")
|
||||
)
|
||||
if line_separated_memories:
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[Message(role="user", contents=[f"{self.context_prompt}\n{line_separated_memories}"])],
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail - memory retrieval is non-critical
|
||||
logger.warning(f"Failed to search contextual memories: {e}")
|
||||
|
||||
async def after_run(
|
||||
self,
|
||||
*,
|
||||
agent: SupportsAgentRun,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Store request/response messages to Foundry Memory for future retrieval.
|
||||
|
||||
This method updates the memory store with conversation messages.
|
||||
The update is debounced by the configured update_delay.
|
||||
"""
|
||||
messages_to_store: list[Message] = list(context.input_messages)
|
||||
if context.response and context.response.messages:
|
||||
messages_to_store.extend(context.response.messages)
|
||||
|
||||
# Filter and convert messages to memory update item format
|
||||
items: list[ResponseInputItemParam] = []
|
||||
for message in messages_to_store:
|
||||
if message.role in {"user", "assistant", "system"} and message.text and message.text.strip():
|
||||
if message.role == "user":
|
||||
items.append({"role": "user", "type": "message", "content": message.text})
|
||||
elif message.role == "assistant":
|
||||
items.append({"role": "assistant", "type": "message", "content": message.text})
|
||||
|
||||
if not items:
|
||||
return
|
||||
|
||||
try:
|
||||
# Fire and forget - don't wait for the update to complete
|
||||
update_poller = await self.project_client.beta.memory_stores.begin_update_memories(
|
||||
name=self.memory_store_name,
|
||||
scope=self.scope or context.session_id, # type: ignore[arg-type]
|
||||
items=items,
|
||||
previous_update_id=state.get("previous_update_id"),
|
||||
update_delay=self.update_delay,
|
||||
)
|
||||
# Store the update_id for next incremental update
|
||||
state["previous_update_id"] = update_poller.update_id
|
||||
|
||||
except Exception as e:
|
||||
# Log but don't fail - memory storage is non-critical
|
||||
logger.warning(f"Failed to update memories: {e}")
|
||||
|
||||
|
||||
__all__ = ["FoundryMemoryProvider"]
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from agent_framework import ChatResponseUpdate, Content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_consent_link(consent_link: str, item_id: str) -> str:
|
||||
"""Validate a consent link is HTTPS with a valid netloc.
|
||||
|
||||
Returns the link unchanged if valid, or an empty string if not.
|
||||
"""
|
||||
parsed = urlparse(consent_link)
|
||||
if parsed.scheme.lower() != "https" or not parsed.netloc:
|
||||
logger.warning(
|
||||
"Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)",
|
||||
item_id,
|
||||
)
|
||||
return ""
|
||||
return consent_link
|
||||
|
||||
|
||||
def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None:
|
||||
"""Parse an oauth_consent_request from a streaming event, if present.
|
||||
|
||||
Returns a ``ChatResponseUpdate`` when *event* is a
|
||||
``response.output_item.added`` carrying an ``oauth_consent_request`` item
|
||||
or a top-level ``response.oauth_consent_requested`` event,
|
||||
or ``None`` so the caller can fall through to the base implementation.
|
||||
"""
|
||||
consent_link: str = ""
|
||||
raw_item: Any = None
|
||||
|
||||
event_type = getattr(event, "type", None)
|
||||
|
||||
if event_type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request":
|
||||
raw_item = event.item
|
||||
consent_link = getattr(raw_item, "consent_link", None) or ""
|
||||
elif event_type == "response.oauth_consent_requested":
|
||||
raw_item = event
|
||||
consent_link = getattr(event, "consent_link", None) or ""
|
||||
else:
|
||||
return None
|
||||
|
||||
item_id = getattr(raw_item, "id", "<unknown>")
|
||||
|
||||
if consent_link:
|
||||
consent_link = _validate_consent_link(consent_link, item_id)
|
||||
|
||||
contents: list[Content] = []
|
||||
if consent_link:
|
||||
contents.append(
|
||||
Content.from_oauth_consent_request(
|
||||
consent_link=consent_link,
|
||||
raw_representation=raw_item,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Received oauth_consent_request output without valid consent_link (item id=%s)",
|
||||
item_id,
|
||||
)
|
||||
|
||||
return ChatResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant",
|
||||
model=model,
|
||||
raw_representation=event,
|
||||
)
|
||||
@@ -0,0 +1,323 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Convert an Agent Framework agent into a Foundry ``PromptAgentDefinition``.
|
||||
|
||||
The converter accepts an :class:`agent_framework.Agent` whose chat client is a
|
||||
:class:`agent_framework_foundry.FoundryChatClient` (or a subclass) and returns
|
||||
a ``PromptAgentDefinition`` ready to publish via
|
||||
``AIProjectClient.agents.create_version(...)``.
|
||||
|
||||
The model is lifted from the bound ``FoundryChatClient`` so the same ``Agent``
|
||||
definition used for local execution can be published as a hosted prompt agent
|
||||
without restating the model deployment name. Generation parameters
|
||||
(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``,
|
||||
``response_format`` / ``text`` / ``verbosity``) are translated from
|
||||
``agent.default_options`` by the local ``_prepare_prompt_agent_options``
|
||||
helper, which reuses the chat client's own request-path helpers so they stay
|
||||
consistent with the agent's local execution.
|
||||
|
||||
Parameters with no Agent Framework equivalent (``structured_inputs``,
|
||||
``rai_config``) are accepted as keyword arguments only.
|
||||
|
||||
Function tools derived from local Python callables are translated to Foundry
|
||||
``FunctionTool`` *declarations* only. Prompt agents are server-side, so the
|
||||
deployed agent will receive the schema for these tools but cannot execute the
|
||||
underlying Python; wiring server-side execution is the caller's responsibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from agent_framework import FunctionTool
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from agent_framework._mcp import MCPTool
|
||||
|
||||
from ._chat_client import RawFoundryChatClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent
|
||||
from azure.ai.projects.models import (
|
||||
PromptAgentDefinition,
|
||||
RaiConfig,
|
||||
StructuredInputDefinition,
|
||||
Tool,
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.TO_PROMPT_AGENT)
|
||||
def to_prompt_agent(
|
||||
agent: Agent,
|
||||
*,
|
||||
structured_inputs: Mapping[str, StructuredInputDefinition] | None = None,
|
||||
rai_config: RaiConfig | None = None,
|
||||
) -> PromptAgentDefinition:
|
||||
"""Convert an ``Agent`` into a Foundry ``PromptAgentDefinition``.
|
||||
|
||||
The agent's chat client must be a :class:`FoundryChatClient` (or any
|
||||
subclass). The model deployment name is lifted from the bound client.
|
||||
|
||||
All generation parameters that have an Agent Framework equivalent
|
||||
(``temperature``, ``top_p``, ``tool_choice``, ``reasoning``,
|
||||
``response_format`` / ``text`` / ``verbosity``) are sourced from
|
||||
``agent.default_options`` and translated by ``_prepare_prompt_agent_options``.
|
||||
The agent is the single source of truth for these; configure them on the
|
||||
``Agent`` (or pass ``default_options={...}`` to its constructor) rather
|
||||
than here.
|
||||
|
||||
Args:
|
||||
agent: An Agent Framework agent whose client is a ``FoundryChatClient``.
|
||||
|
||||
Keyword Args:
|
||||
structured_inputs: Mapping of structured input names to
|
||||
``StructuredInputDefinition`` entries. Foundry-only; no
|
||||
``ChatOptions`` equivalent.
|
||||
rai_config: Foundry ``RaiConfig`` to attach to the definition.
|
||||
Foundry-only; no ``ChatOptions`` equivalent.
|
||||
|
||||
Returns:
|
||||
A ``PromptAgentDefinition`` carrying the agent's model, instructions,
|
||||
tools, and generation parameters. Pass it to
|
||||
``AIProjectClient.agents.create_version(...)`` to publish.
|
||||
"""
|
||||
if not isinstance(agent.client, RawFoundryChatClient):
|
||||
raise TypeError(
|
||||
"Creating a Foundry Prompt Agent requires an Agent whose client is a FoundryChatClient; "
|
||||
f"got {type(agent.client).__name__!r}."
|
||||
)
|
||||
|
||||
# Match the resolution order Agent.__init__ uses when building default_options:
|
||||
# an agent-level model override in default_options wins over the bound client's model.
|
||||
model = agent.default_options.get("model") or agent.client.model
|
||||
if not model:
|
||||
raise ValueError(
|
||||
"Agent has no model. Set 'model' on the FoundryChatClient (via the FOUNDRY_MODEL "
|
||||
"environment variable or the model= argument), or pass default_options={'model': ...} "
|
||||
"to the Agent before converting."
|
||||
)
|
||||
|
||||
instructions = agent.default_options.get("instructions")
|
||||
tools = _convert_tools(
|
||||
agent.default_options.get("tools", []),
|
||||
getattr(agent, "mcp_tools", []),
|
||||
)
|
||||
|
||||
translated = _prepare_prompt_agent_options(
|
||||
agent.client,
|
||||
agent.default_options,
|
||||
has_tools=bool(tools),
|
||||
)
|
||||
|
||||
from azure.ai.projects.models import PromptAgentDefinition
|
||||
|
||||
kwargs: dict[str, Any] = {"model": model}
|
||||
if instructions is not None:
|
||||
kwargs["instructions"] = instructions
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
kwargs.update(translated)
|
||||
if structured_inputs is not None:
|
||||
kwargs["structured_inputs"] = dict(structured_inputs)
|
||||
if rai_config is not None:
|
||||
kwargs["rai_config"] = rai_config
|
||||
|
||||
return PromptAgentDefinition(**kwargs)
|
||||
|
||||
|
||||
def _prepare_prompt_agent_options(
|
||||
client: RawFoundryChatClient[Any],
|
||||
default_options: Mapping[str, Any],
|
||||
*,
|
||||
has_tools: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Translate ``default_options`` into ``PromptAgentDefinition`` field kwargs.
|
||||
|
||||
Reuses the chat client's own request-path helpers
|
||||
(``validate_tool_mode``, ``client._prepare_response_and_text_format``,
|
||||
``type_to_text_format_param``) so a published prompt agent stays
|
||||
consistent with the agent's local execution.
|
||||
|
||||
Only fields with a direct ``PromptAgentDefinition`` counterpart are
|
||||
translated: ``temperature``, ``top_p``, ``reasoning``, ``tool_choice``,
|
||||
``response_format`` / ``text`` / ``verbosity``. Other ``OpenAIChatOptions``
|
||||
keys (``include``, ``prompt``, ``store``, etc.) have no prompt-agent
|
||||
equivalent and are intentionally ignored. The input mapping is never
|
||||
mutated.
|
||||
|
||||
Args:
|
||||
client: The bound ``FoundryChatClient`` (used to reuse its
|
||||
``_prepare_response_and_text_format`` for dict-shaped
|
||||
``response_format`` values).
|
||||
default_options: The agent's ``default_options`` mapping.
|
||||
|
||||
Keyword Args:
|
||||
has_tools: When ``False``, ``tool_choice`` is dropped (no point
|
||||
emitting a tool selection policy when the definition has no
|
||||
tools), mirroring the regular request path in
|
||||
``_prepare_options``.
|
||||
|
||||
Returns:
|
||||
A dict ready to splat into ``PromptAgentDefinition(**...)``. Unset
|
||||
fields are omitted.
|
||||
"""
|
||||
from agent_framework._types import validate_tool_mode
|
||||
from azure.ai.projects.models import (
|
||||
PromptAgentDefinitionTextOptions,
|
||||
Reasoning,
|
||||
ToolChoiceAllowed,
|
||||
ToolChoiceFunction,
|
||||
)
|
||||
from openai.lib._parsing._responses import (
|
||||
type_to_text_format_param,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
if (temperature := default_options.get("temperature")) is not None:
|
||||
result["temperature"] = temperature
|
||||
if (top_p := default_options.get("top_p")) is not None:
|
||||
result["top_p"] = top_p
|
||||
|
||||
if (reasoning := default_options.get("reasoning")) is not None:
|
||||
if isinstance(reasoning, Reasoning):
|
||||
result["reasoning"] = reasoning
|
||||
elif isinstance(reasoning, Mapping):
|
||||
result["reasoning"] = Reasoning(**dict(cast("Mapping[str, Any]", reasoning)))
|
||||
else:
|
||||
result["reasoning"] = reasoning
|
||||
|
||||
if has_tools and (tool_choice := default_options.get("tool_choice")) is not None:
|
||||
tool_mode = validate_tool_mode(tool_choice)
|
||||
if tool_mode is not None:
|
||||
mode = tool_mode.get("mode")
|
||||
func_name = tool_mode.get("required_function_name")
|
||||
allowed = tool_mode.get("allowed_tools")
|
||||
if mode == "required" and func_name is not None:
|
||||
result["tool_choice"] = ToolChoiceFunction(name=func_name)
|
||||
elif mode == "auto" and allowed is not None:
|
||||
result["tool_choice"] = ToolChoiceAllowed(
|
||||
mode="auto",
|
||||
tools=[{"type": "function", "name": name} for name in allowed],
|
||||
)
|
||||
else:
|
||||
result["tool_choice"] = mode
|
||||
|
||||
existing_text = default_options.get("text")
|
||||
text_config: dict[str, Any] | None = (
|
||||
dict(cast("Mapping[str, Any]", existing_text)) if isinstance(existing_text, Mapping) else None
|
||||
)
|
||||
response_format = default_options.get("response_format")
|
||||
if response_format is not None or text_config is not None:
|
||||
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
||||
format_config = dict(type_to_text_format_param(response_format))
|
||||
text_config = dict(text_config) if text_config else {}
|
||||
if "format" in text_config and text_config["format"] != format_config:
|
||||
raise ValueError("Conflicting response_format definitions detected.")
|
||||
text_config["format"] = format_config
|
||||
elif response_format is not None:
|
||||
response_format_model, text_config = client._prepare_response_and_text_format( # pyright: ignore[reportPrivateUsage]
|
||||
response_format=response_format, text_config=text_config
|
||||
)
|
||||
if response_format_model is not None:
|
||||
raise ValueError(
|
||||
"response_format must be a Pydantic BaseModel subclass or a mapping when "
|
||||
"converting to a PromptAgentDefinition."
|
||||
)
|
||||
if (verbosity := default_options.get("verbosity")) is not None:
|
||||
text_config = dict(text_config) if text_config else {}
|
||||
text_config["verbosity"] = verbosity
|
||||
if text_config:
|
||||
result["text"] = PromptAgentDefinitionTextOptions(text_config)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _convert_tools(
|
||||
tools: Iterable[Any] | None,
|
||||
mcp_tools: Iterable[MCPTool] | None,
|
||||
) -> list[Tool]:
|
||||
"""Map AF agent tools to Foundry ``PromptAgentDefinition`` tool entries.
|
||||
|
||||
Tool sources walked, in order:
|
||||
|
||||
* ``agent.default_options["tools"]`` — function tools and hosted Foundry SDK
|
||||
tool instances (returned by ``FoundryChatClient.get_*_tool()``).
|
||||
* ``agent.mcp_tools`` — local Agent Framework MCP servers (split off from
|
||||
the tools list by ``normalize_tools()``). These cannot be published as
|
||||
prompt-agent tools; the caller must use the hosted MCP factory instead.
|
||||
|
||||
Hosted SDK tool instances are passed through unchanged. Mapping/dict tools
|
||||
are passed through after light validation. Anything else raises
|
||||
``ValueError`` with a message that names the offending type.
|
||||
"""
|
||||
from azure.ai.projects.models import Tool as ProjectsTool
|
||||
|
||||
converted: list[Tool] = []
|
||||
|
||||
for tool_item in tools or ():
|
||||
if isinstance(tool_item, ProjectsTool):
|
||||
converted.append(tool_item)
|
||||
continue
|
||||
if isinstance(tool_item, FunctionTool):
|
||||
converted.append(_function_tool_to_foundry(tool_item))
|
||||
continue
|
||||
if isinstance(tool_item, Mapping):
|
||||
converted.append(_validate_mapping_tool(cast("Mapping[str, Any]", tool_item)))
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Unsupported tool type for PromptAgentDefinition: {type(tool_item).__name__}. "
|
||||
"Use FoundryChatClient.get_*_tool() helpers, a callable / FunctionTool, "
|
||||
"or a dict matching the Foundry tool schema."
|
||||
)
|
||||
|
||||
for mcp_tool in mcp_tools or ():
|
||||
raise ValueError(
|
||||
f"Local MCP tool {mcp_tool.name!r} cannot be published as a prompt-agent tool. "
|
||||
"Use FoundryChatClient.get_mcp_tool(...) to register a hosted MCP server instead."
|
||||
)
|
||||
|
||||
return converted
|
||||
|
||||
|
||||
def _function_tool_to_foundry(tool_item: FunctionTool) -> Tool:
|
||||
"""Build a Foundry ``FunctionTool`` declaration from an AF ``FunctionTool``.
|
||||
|
||||
The result carries only the schema (name, description, parameters). It is a
|
||||
declaration of the tool the prompt agent may call; server-side execution
|
||||
must be wired separately by the caller.
|
||||
"""
|
||||
try:
|
||||
from azure.ai.projects.models import FunctionTool as ProjectsFunctionTool
|
||||
except ImportError as exc: # pragma: no cover - sanity guard
|
||||
raise ImportError(
|
||||
"FunctionTool is not available in the installed azure-ai-projects. Upgrade azure-ai-projects."
|
||||
) from exc
|
||||
|
||||
return ProjectsFunctionTool(
|
||||
name=tool_item.name,
|
||||
description=tool_item.description or "",
|
||||
parameters=tool_item.parameters(),
|
||||
strict=True,
|
||||
)
|
||||
|
||||
|
||||
def _validate_mapping_tool(tool_item: Mapping[str, Any]) -> Tool:
|
||||
"""Validate a dict-shaped tool and instantiate a Foundry ``Tool``.
|
||||
|
||||
The Foundry SDK can rehydrate a tool model from its raw JSON mapping via
|
||||
the discriminator on ``type``. We require the ``type`` field so the
|
||||
failure mode is obvious; everything else is dispatched through the SDK's
|
||||
``Tool._deserialize`` entry point so the concrete subclass
|
||||
(e.g. ``FunctionTool``, ``WebSearchTool``) is materialized rather than a
|
||||
generic ``Tool`` instance.
|
||||
"""
|
||||
from azure.ai.projects.models import Tool as ProjectsTool
|
||||
|
||||
if "type" not in tool_item:
|
||||
raise ValueError("Dict-shaped tools must include a 'type' field matching a Foundry tool discriminator.")
|
||||
# ``_deserialize`` is the SDK's discriminator-aware entry point. It is marked
|
||||
# protected by convention but is the standard way to rehydrate polymorphic
|
||||
# azure-sdk-for-python models from a raw mapping.
|
||||
return cast("Tool", ProjectsTool._deserialize(dict(tool_item), [])) # type: ignore[no-untyped-call]
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shared tool helpers for Foundry chat clients.
|
||||
|
||||
Includes Responses-API payload sanitization for Foundry hosted tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
|
||||
|
||||
def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None:
|
||||
"""Fail fast on hosted tool payloads that would always be rejected by the Responses API.
|
||||
|
||||
These mismatches are not injectable defaults — the caller must supply the
|
||||
missing information — so surfacing a clear error here points at the tool
|
||||
definition instead of letting the API return a generic 400.
|
||||
"""
|
||||
tool_type = sanitized.get("type")
|
||||
if tool_type == "file_search" and not sanitized.get("vector_store_ids"):
|
||||
raise ValueError(
|
||||
"'file_search' tool is missing required 'vector_store_ids'. "
|
||||
"Update the tool definition to include at least one vector store ID."
|
||||
)
|
||||
if tool_type == "mcp" and not sanitized.get("server_url") and not sanitized.get("project_connection_id"):
|
||||
raise ValueError(
|
||||
"'mcp' tool is missing both 'server_url' and 'project_connection_id'. "
|
||||
"Update the tool definition to include one of these."
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_foundry_response_tool(tool_item: Any) -> Any: # pyright: ignore[reportUnusedFunction]
|
||||
"""Return a Responses-API-safe tool payload for Foundry hosted tools.
|
||||
|
||||
Reconciles known mismatches between hosted tool definitions and the Responses API:
|
||||
|
||||
1. Hosted tool objects may carry read-model fields such as top-level ``name``
|
||||
and ``description``. The Responses API rejects at least ``name`` with
|
||||
``Unknown parameter: 'tools[0].name'``. These fields are stripped from
|
||||
non-function hosted tool payloads.
|
||||
2. ``code_interpreter`` tools without a ``container`` field (the Azure SDK
|
||||
treats it as optional) are rejected by the Responses API with
|
||||
``Missing required parameter: 'tools[N].container'``. A default
|
||||
``{"type": "auto"}`` container is injected when absent.
|
||||
3. Hosted tools that are structurally incomplete in ways that cannot be
|
||||
defaulted (``file_search`` without ``vector_store_ids``, ``mcp`` without
|
||||
either ``server_url`` or ``project_connection_id``) raise ``ValueError``
|
||||
with a message that points at the tool definition.
|
||||
"""
|
||||
if isinstance(tool_item, FoundryMCPTool):
|
||||
sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item))
|
||||
sanitized.pop("name", None)
|
||||
sanitized.pop("description", None)
|
||||
_validate_hosted_tool_payload(sanitized)
|
||||
return sanitized
|
||||
|
||||
if isinstance(tool_item, Mapping):
|
||||
mapping = cast("Mapping[str, Any]", tool_item)
|
||||
if "type" in mapping and mapping.get("type") not in {"function", "custom"}:
|
||||
sanitized = dict(mapping)
|
||||
sanitized.pop("name", None)
|
||||
sanitized.pop("description", None)
|
||||
if sanitized.get("type") == "code_interpreter" and "container" not in sanitized:
|
||||
sanitized["container"] = {"type": "auto"}
|
||||
_validate_hosted_tool_payload(sanitized)
|
||||
return sanitized
|
||||
|
||||
return cast(Any, tool_item)
|
||||
Reference in New Issue
Block a user