chore: import upstream snapshot with attribution
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) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
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) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,258 @@
|
||||
# Agent Framework Foundry
|
||||
|
||||
This package contains the Microsoft Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, Foundry embedding clients, and Foundry memory providers.
|
||||
|
||||
## Toolboxes
|
||||
|
||||
A *toolbox* is a named, versioned bundle of hosted tool configurations — code interpreter, file search, image generation, MCP, web search, and so on — stored inside a Microsoft Foundry project. Toolboxes let you manage tool configuration once and reuse it across agents.
|
||||
|
||||
### Authoring a toolbox
|
||||
|
||||
Toolboxes can be authored two ways:
|
||||
|
||||
- **Foundry portal** — create and version toolboxes through the UI without touching code.
|
||||
- **Programmatically** — use the [`azure-ai-projects`](https://pypi.org/project/azure-ai-projects/) SDK to create, update, and version toolboxes from Python.
|
||||
|
||||
> Toolbox authoring APIs (`ToolboxVersionObject`, `ToolboxObject`, `project_client.beta.toolboxes.*`) require `azure-ai-projects>=2.1.0`. Earlier versions can only consume toolboxes that already exist.
|
||||
|
||||
### Using toolboxes with `FoundryAgent`
|
||||
|
||||
For hosted `FoundryAgent`, the toolbox must already be attached to the agent in the Microsoft Foundry project. Once attached, the agent invokes its toolbox tools transparently — no client-side wiring required — and you interact with the agent the same way you would with any other tool-equipped Foundry agent.
|
||||
|
||||
### Using toolboxes with `FoundryChatClient`
|
||||
|
||||
Each toolbox is reachable as an MCP server. Connect to the toolbox's MCP endpoint with `MCPStreamableHTTPTool` — the agent then discovers and calls its tools over MCP at runtime:
|
||||
|
||||
```python
|
||||
from agent_framework import Agent, MCPStreamableHTTPTool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
|
||||
async with Agent(
|
||||
client=FoundryChatClient(...),
|
||||
instructions="You are a helpful assistant. Use the toolbox tools when useful.",
|
||||
tools=MCPStreamableHTTPTool(
|
||||
name="my_toolbox",
|
||||
description="Tools served by my Foundry toolbox",
|
||||
url="https://<your-toolbox-mcp-endpoint>",
|
||||
),
|
||||
) as agent:
|
||||
result = await agent.run("What tools are available?")
|
||||
print(result.text)
|
||||
```
|
||||
|
||||
## Hosted tool factories
|
||||
|
||||
`FoundryChatClient` exposes static factory methods that return Foundry SDK tool
|
||||
configurations ready to pass to an `Agent`'s `tools=[...]` argument. These
|
||||
factories don't require a `FoundryChatClient` instance — you can call them
|
||||
statically and reuse the same tool configuration across agents.
|
||||
|
||||
```python
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(...),
|
||||
instructions="...",
|
||||
tools=[
|
||||
FoundryChatClient.get_web_search_tool(),
|
||||
FoundryChatClient.get_code_interpreter_tool(),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Generally available factories: `get_code_interpreter_tool`,
|
||||
`get_file_search_tool`, `get_web_search_tool`,
|
||||
`get_image_generation_tool`, `get_mcp_tool`.
|
||||
|
||||
> **Choosing a web grounding tool.** `get_web_search_tool` is the recommended
|
||||
> default — it requires no separate Bing resource and works with Azure OpenAI
|
||||
> models out of the box. Reach for `get_bing_grounding_tool` (experimental,
|
||||
> see below) when you need finer Bing parameters (`count`, `freshness`,
|
||||
> `market`, `set_lang`), are grounding non-OpenAI Foundry models, or are
|
||||
> migrating from Grounding with Bing Search on the classic platform — it
|
||||
> requires a Grounding with Bing Search Azure resource that you manage.
|
||||
> `get_bing_custom_search_tool` (also experimental) is for grounding
|
||||
> restricted to a curated list of domains via a Bing Custom Search instance.
|
||||
> See the
|
||||
> [web grounding overview](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/web-overview)
|
||||
> for the full comparison.
|
||||
|
||||
> **Experimental — `ExperimentalFeature.FOUNDRY_TOOLS`.** The following
|
||||
> factories wrap GA Foundry tool SDK classes but are new wrappers in
|
||||
> `agent-framework-foundry` and may change before the wrappers themselves
|
||||
> reach GA. Calls emit an `ExperimentalWarning` the first time the
|
||||
> `FOUNDRY_TOOLS` feature is exercised in a process (then deduplicated).
|
||||
|
||||
| Factory | Foundry SDK tool |
|
||||
|---------|-----------------|
|
||||
| `get_azure_ai_search_tool(index_connection_id, index_name, ...)` | `AzureAISearchTool` |
|
||||
| `get_bing_grounding_tool(connection_id, ...)` | `BingGroundingTool` |
|
||||
|
||||
> **Experimental — `ExperimentalFeature.FOUNDRY_PREVIEW_TOOLS`.** The
|
||||
> following factories wrap **preview** Foundry tool SDK types — the underlying
|
||||
> Foundry capability itself is in preview and may change or be removed before
|
||||
> reaching GA. Calls emit a separate `ExperimentalWarning` the first time the
|
||||
> `FOUNDRY_PREVIEW_TOOLS` feature is exercised in a process (then
|
||||
> deduplicated). Use `FOUNDRY_TOOLS` for "wrapper is new" and
|
||||
> `FOUNDRY_PREVIEW_TOOLS` for "underlying Foundry feature is preview".
|
||||
|
||||
| Factory | Foundry SDK tool |
|
||||
|---------|-----------------|
|
||||
| `get_sharepoint_tool(connection_id)` | `SharepointPreviewTool` |
|
||||
| `get_fabric_tool(connection_id)` | `MicrosoftFabricPreviewTool` |
|
||||
| `get_memory_search_tool(memory_store_name, scope, ...)` | `MemorySearchPreviewTool` |
|
||||
| `get_computer_use_tool(environment, display_width, display_height)` | `ComputerUsePreviewTool` |
|
||||
| `get_browser_automation_tool(connection_id)` | `BrowserAutomationPreviewTool` |
|
||||
| `get_bing_custom_search_tool(connection_id, instance_name, ...)` | `BingCustomSearchPreviewTool` |
|
||||
| `get_a2a_tool(base_url=..., project_connection_id=..., ...)` | `A2APreviewTool` |
|
||||
|
||||
## Creating Foundry conversation sessions
|
||||
|
||||
`FoundryAgent.create_conversation()` creates a server-side Foundry
|
||||
project conversation and returns an `AgentSession` that can be passed to
|
||||
`agent.run(...)` without reaching into the raw OpenAI client.
|
||||
|
||||
```python
|
||||
from agent_framework.foundry import FoundryAgent
|
||||
|
||||
agent = FoundryAgent(
|
||||
project_endpoint=project_endpoint,
|
||||
agent_name="travel-agent",
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
session = await agent.create_conversation()
|
||||
response = await agent.run("Help me plan a trip to Seattle.", session=session)
|
||||
```
|
||||
|
||||
This is separate from hosted-agent `isolation_key` sessions: the created
|
||||
conversation ID is stored on `AgentSession.service_session_id`, while the local
|
||||
`session_id` remains available for application/session storage.
|
||||
|
||||
## Publishing an agent as a Foundry prompt agent
|
||||
|
||||
> **Experimental — `ExperimentalFeature.TO_PROMPT_AGENT`.** `to_prompt_agent`
|
||||
> is a preview API and may change before reaching GA. The warning fires the
|
||||
> first time the `TO_PROMPT_AGENT` feature is exercised in a process and is
|
||||
> then deduplicated.
|
||||
|
||||
`to_prompt_agent(agent)` converts an `Agent` whose chat client is a
|
||||
`FoundryChatClient` into a Foundry `PromptAgentDefinition` that can be
|
||||
published with `AIProjectClient.agents.create_version(...)`. The model is read
|
||||
from `default_options["model"]` first and falls back to the bound
|
||||
`FoundryChatClient.model` (matching `Agent.__init__`'s resolution order), so
|
||||
the same agent definition you run locally can be published as a hosted prompt
|
||||
agent without restating the model deployment name.
|
||||
|
||||
Every generation parameter that has an Agent Framework equivalent is sourced
|
||||
from `agent.default_options` and translated into the matching Foundry shape by
|
||||
`_prepare_prompt_agent_options` (a module-private helper in
|
||||
`agent_framework_foundry._to_prompt_agent` that reuses the chat client's own
|
||||
request-path helpers):
|
||||
|
||||
| `default_options` key | `PromptAgentDefinition` field |
|
||||
|---|---|
|
||||
| `temperature` | `temperature` |
|
||||
| `top_p` | `top_p` |
|
||||
| `tool_choice` (dropped when no tools) | `tool_choice` (`str` / `ToolChoiceFunction` / `ToolChoiceAllowed`) |
|
||||
| `reasoning` (dict or `Reasoning`) | `reasoning` |
|
||||
| `response_format` (dict or `BaseModel`) | `text.format` |
|
||||
| `verbosity` | `text.verbosity` |
|
||||
| `text` | merged into `text` |
|
||||
|
||||
This keeps the `Agent` as the single source of truth for everything it can
|
||||
already express. Only Foundry-specific fields with no Agent Framework
|
||||
equivalent are accepted as keyword arguments on `to_prompt_agent`:
|
||||
|
||||
- `structured_inputs` — `dict[str, StructuredInputDefinition]`
|
||||
- `rai_config` — `RaiConfig`
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient, to_prompt_agent
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
credential = AzureCliCredential()
|
||||
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
|
||||
|
||||
agent = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=project_endpoint,
|
||||
model="gpt-4o",
|
||||
credential=credential,
|
||||
),
|
||||
name="travel-agent",
|
||||
description="Helps Contoso employees book travel.",
|
||||
instructions="You are a helpful travel assistant.",
|
||||
tools=[
|
||||
FoundryChatClient.get_web_search_tool(),
|
||||
FoundryChatClient.get_code_interpreter_tool(),
|
||||
],
|
||||
# Generation parameters set on the Agent flow through automatically.
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.95,
|
||||
"reasoning": {"effort": "medium"},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
project_client = AIProjectClient(endpoint=project_endpoint, credential=credential)
|
||||
created = await project_client.agents.create_version(
|
||||
agent_name=agent.name,
|
||||
definition=definition,
|
||||
description=agent.description,
|
||||
)
|
||||
print(f"Published {created.name} v{created.version}")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- `agent.client` must be a `FoundryChatClient` (or subclass) — otherwise the
|
||||
converter raises `TypeError`.
|
||||
- The bound client must have a `model` set — otherwise the converter raises
|
||||
`ValueError`.
|
||||
- Foundry SDK tool instances returned by `FoundryChatClient.get_*_tool()` are
|
||||
passed through unchanged.
|
||||
- AF `FunctionTool` instances (and `@tool`-decorated callables) are emitted as
|
||||
Foundry `FunctionTool` **declarations** — the prompt agent receives the
|
||||
schema only, not the Python implementation. To execute the function when
|
||||
invoking the deployed prompt agent, connect with `FoundryAgent` and pass the
|
||||
same callable via `tools=`:
|
||||
|
||||
```python
|
||||
from agent_framework.foundry import FoundryAgent
|
||||
|
||||
deployed = FoundryAgent(
|
||||
project_endpoint=project_endpoint,
|
||||
agent_name="travel-agent",
|
||||
credential=credential,
|
||||
tools=[book_hotel], # same @tool-decorated callable used at publish time
|
||||
)
|
||||
result = await deployed.run("Book me a hotel in Seattle for 3 nights.")
|
||||
```
|
||||
|
||||
`FoundryAgent` runs the function locally when the prompt agent calls it, so
|
||||
the declaration on the server and the implementation on the client stay in
|
||||
sync via the shared `@tool` definition.
|
||||
- Local Agent Framework MCP tools cannot be published as prompt-agent tools —
|
||||
the converter raises `ValueError` and points at
|
||||
`FoundryChatClient.get_mcp_tool(...)` for hosted MCP servers.
|
||||
|
||||
See the runnable example under `samples/02-agents/providers/foundry/`:
|
||||
|
||||
- [`foundry_prompt_agents.py`](../../samples/02-agents/providers/foundry/foundry_prompt_agents.py)
|
||||
— publish with `to_prompt_agent`, then connect back with `FoundryAgent` and
|
||||
execute the same local `@tool` callable that the deployed prompt agent
|
||||
invokes by name.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,107 @@
|
||||
[project]
|
||||
name = "agent-framework-foundry"
|
||||
description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.10.1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.11.0,<2",
|
||||
"agent-framework-openai>=1.10.0,<2",
|
||||
"aiohttp>=3.9,<4",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.2.0,<2.3.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_foundry"]
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_foundry"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_foundry --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[tool.poe.tasks.integration-tests]
|
||||
help = "Run the package integration test suite."
|
||||
cmd = """
|
||||
pytest --import-mode=importlib
|
||||
-n logical --dist worksteal
|
||||
tests
|
||||
"""
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from pytest import fixture
|
||||
|
||||
|
||||
@fixture
|
||||
def exclude_list(request: Any) -> list[str]:
|
||||
"""Fixture that returns a list of environment variables to exclude."""
|
||||
return request.param if hasattr(request, "param") else []
|
||||
|
||||
|
||||
@fixture
|
||||
def override_env_param_dict(request: Any) -> dict[str, str]:
|
||||
"""Fixture that returns a dict of environment variables to override."""
|
||||
return request.param if hasattr(request, "param") else {}
|
||||
|
||||
|
||||
@fixture()
|
||||
def foundry_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
|
||||
"""Fixture to set environment variables for Foundry settings."""
|
||||
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "https://test-project.services.ai.azure.com/",
|
||||
"FOUNDRY_MODEL": "test-gpt-4o",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict) # type: ignore
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key in exclude_list:
|
||||
monkeypatch.delenv(key, raising=False) # type: ignore
|
||||
continue
|
||||
monkeypatch.setenv(key, value) # type: ignore
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_agents_client() -> MagicMock:
|
||||
"""Fixture that provides a mock AgentsClient."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Mock agents property
|
||||
mock_client.create_agent = AsyncMock()
|
||||
mock_client.delete_agent = AsyncMock()
|
||||
|
||||
# Mock agent creation response
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.id = "test-agent-id"
|
||||
mock_client.create_agent.return_value = mock_agent
|
||||
|
||||
# Mock threads property
|
||||
mock_client.threads = MagicMock()
|
||||
mock_client.threads.create = AsyncMock()
|
||||
mock_client.messages.create = AsyncMock()
|
||||
|
||||
# Mock runs property
|
||||
mock_client.runs = MagicMock()
|
||||
mock_client.runs.list = AsyncMock()
|
||||
mock_client.runs.cancel = AsyncMock()
|
||||
mock_client.runs.stream = AsyncMock()
|
||||
mock_client.runs.submit_tool_outputs_stream = AsyncMock()
|
||||
|
||||
return mock_client
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_azure_credential() -> MagicMock:
|
||||
"""Fixture that provides a mock AsyncTokenCredential."""
|
||||
return MagicMock()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import Content
|
||||
|
||||
from agent_framework_foundry import (
|
||||
FoundryEmbeddingClient,
|
||||
FoundryEmbeddingOptions,
|
||||
RawFoundryEmbeddingClient,
|
||||
)
|
||||
|
||||
|
||||
def _make_embed_response(
|
||||
embeddings: Sequence[list[float]],
|
||||
model: str = "test-model",
|
||||
prompt_tokens: int = 10,
|
||||
) -> MagicMock:
|
||||
"""Create a mock EmbeddingsResult."""
|
||||
data = []
|
||||
for emb in embeddings:
|
||||
item = MagicMock()
|
||||
item.embedding = emb
|
||||
data.append(item)
|
||||
|
||||
usage = MagicMock()
|
||||
usage.prompt_tokens = prompt_tokens
|
||||
usage.completion_tokens = 0
|
||||
|
||||
result = MagicMock()
|
||||
result.data = data
|
||||
result.model = model
|
||||
result.usage = usage
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_text_client() -> AsyncMock:
|
||||
"""Create a mock text EmbeddingsClient."""
|
||||
client = AsyncMock()
|
||||
client.embed = AsyncMock(return_value=_make_embed_response([[0.1, 0.2, 0.3]]))
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_image_client() -> AsyncMock:
|
||||
"""Create a mock image ImageEmbeddingsClient."""
|
||||
client = AsyncMock()
|
||||
client.embed = AsyncMock(return_value=_make_embed_response([[0.4, 0.5, 0.6]]))
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raw_client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> RawFoundryEmbeddingClient[Any]:
|
||||
"""Create a RawFoundryEmbeddingClient with mocked SDK clients."""
|
||||
return RawFoundryEmbeddingClient(
|
||||
model="test-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> FoundryEmbeddingClient[Any]:
|
||||
"""Create a FoundryEmbeddingClient with mocked SDK clients."""
|
||||
return FoundryEmbeddingClient(
|
||||
model="test-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
)
|
||||
|
||||
|
||||
class TestRawFoundryEmbeddingClient:
|
||||
"""Tests for the raw Foundry embedding client."""
|
||||
|
||||
async def test_text_embeddings(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""Text inputs are dispatched to the text client."""
|
||||
result = await raw_client.get_embeddings(["hello", "world"])
|
||||
assert result is not None
|
||||
call_kwargs = mock_text_client.embed.call_args
|
||||
assert call_kwargs.kwargs["input"] == ["hello", "world"]
|
||||
assert call_kwargs.kwargs["model"] == "test-model"
|
||||
|
||||
async def test_text_content_embeddings(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""Content.from_text() inputs are dispatched to the text client."""
|
||||
text_content = Content.from_text("hello")
|
||||
await raw_client.get_embeddings([text_content])
|
||||
|
||||
mock_text_client.embed.assert_called_once()
|
||||
call_kwargs = mock_text_client.embed.call_args
|
||||
assert call_kwargs.kwargs["input"] == ["hello"]
|
||||
|
||||
async def test_image_content_embeddings(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_image_client: AsyncMock
|
||||
) -> None:
|
||||
"""Image Content inputs are dispatched to the image client."""
|
||||
image_content = Content.from_data(data=b"\x89PNG", media_type="image/png")
|
||||
await raw_client.get_embeddings([image_content])
|
||||
|
||||
mock_image_client.embed.assert_called_once()
|
||||
call_kwargs = mock_image_client.embed.call_args
|
||||
image_inputs = call_kwargs.kwargs["input"]
|
||||
assert len(image_inputs) == 1
|
||||
assert image_inputs[0].image == image_content.uri
|
||||
|
||||
async def test_mixed_text_and_image(
|
||||
self,
|
||||
raw_client: RawFoundryEmbeddingClient[Any],
|
||||
mock_text_client: AsyncMock,
|
||||
mock_image_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Mixed text and image inputs are dispatched to the correct clients."""
|
||||
mock_text_client.embed.return_value = _make_embed_response([[0.1, 0.2]])
|
||||
mock_image_client.embed.return_value = _make_embed_response([[0.3, 0.4]])
|
||||
|
||||
image = Content.from_data(data=b"\x89PNG", media_type="image/png")
|
||||
await raw_client.get_embeddings(["hello", image, "world"])
|
||||
|
||||
# Text client gets "hello" and "world"
|
||||
text_call = mock_text_client.embed.call_args
|
||||
assert text_call.kwargs["input"] == ["hello", "world"]
|
||||
|
||||
# Image client gets the image
|
||||
image_call = mock_image_client.embed.call_args
|
||||
assert len(image_call.kwargs["input"]) == 1
|
||||
|
||||
async def test_empty_input(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None:
|
||||
"""Empty input returns empty result."""
|
||||
result = await raw_client.get_embeddings([])
|
||||
assert len(result) == 0
|
||||
|
||||
async def test_options_passed_through(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""Options are passed through to the SDK."""
|
||||
options: FoundryEmbeddingOptions = {
|
||||
"dimensions": 512,
|
||||
"input_type": "document",
|
||||
"encoding_format": "float",
|
||||
}
|
||||
await raw_client.get_embeddings(["hello"], options=options)
|
||||
|
||||
call_kwargs = mock_text_client.embed.call_args
|
||||
assert call_kwargs.kwargs["dimensions"] == 512
|
||||
assert call_kwargs.kwargs["input_type"] == "document"
|
||||
assert call_kwargs.kwargs["encoding_format"] == "float"
|
||||
|
||||
async def test_model_override_in_options(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""model in options overrides the default."""
|
||||
options: FoundryEmbeddingOptions = {"model": "custom-model"}
|
||||
await raw_client.get_embeddings(["hello"], options=options)
|
||||
|
||||
call_kwargs = mock_text_client.embed.call_args
|
||||
assert call_kwargs.kwargs["model"] == "custom-model"
|
||||
|
||||
async def test_unsupported_content_type_raises(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None:
|
||||
"""Non-text, non-image Content raises ValueError."""
|
||||
error_content = Content("error", message="fail")
|
||||
with pytest.raises(ValueError, match="Unsupported Content type"):
|
||||
await raw_client.get_embeddings([error_content])
|
||||
|
||||
async def test_usage_metadata(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""Usage metadata is populated from the response."""
|
||||
mock_text_client.embed.return_value = _make_embed_response([[0.1, 0.2]], prompt_tokens=42)
|
||||
result = await raw_client.get_embeddings(["hello"])
|
||||
assert result.usage is not None
|
||||
assert result.usage["input_token_count"] == 42
|
||||
|
||||
def test_service_url(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None:
|
||||
"""service_url returns the configured endpoint."""
|
||||
assert raw_client.service_url() == "https://test.inference.ai.azure.com"
|
||||
|
||||
def test_settings_from_env(self) -> None:
|
||||
"""Settings are loaded from environment variables."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FOUNDRY_MODELS_ENDPOINT": "https://env.inference.ai.azure.com",
|
||||
"FOUNDRY_MODELS_API_KEY": "env-key",
|
||||
"FOUNDRY_EMBEDDING_MODEL": "env-model",
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
|
||||
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
|
||||
):
|
||||
client = RawFoundryEmbeddingClient()
|
||||
assert client.model == "env-model"
|
||||
assert client.image_model == "env-model" # falls back to model
|
||||
|
||||
def test_image_model_from_env(self) -> None:
|
||||
"""image_model is loaded from its own environment variable."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FOUNDRY_MODELS_ENDPOINT": "https://env.inference.ai.azure.com",
|
||||
"FOUNDRY_MODELS_API_KEY": "env-key",
|
||||
"FOUNDRY_EMBEDDING_MODEL": "text-model",
|
||||
"FOUNDRY_IMAGE_EMBEDDING_MODEL": "image-model",
|
||||
},
|
||||
),
|
||||
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
|
||||
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
|
||||
):
|
||||
client = RawFoundryEmbeddingClient()
|
||||
assert client.model == "text-model"
|
||||
assert client.image_model == "image-model"
|
||||
|
||||
def test_image_model_explicit(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None:
|
||||
"""image_model can be set explicitly."""
|
||||
client = RawFoundryEmbeddingClient(
|
||||
model="text-model",
|
||||
image_model="image-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
)
|
||||
assert client.model == "text-model"
|
||||
assert client.image_model == "image-model"
|
||||
|
||||
async def test_image_model_sent_to_image_client(
|
||||
self, mock_text_client: AsyncMock, mock_image_client: AsyncMock
|
||||
) -> None:
|
||||
"""image_model is passed to the image client embed call."""
|
||||
client = RawFoundryEmbeddingClient(
|
||||
model="text-model",
|
||||
image_model="image-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
)
|
||||
image_content = Content.from_data(data=b"\x89PNG", media_type="image/png")
|
||||
await client.get_embeddings([image_content])
|
||||
call_kwargs = mock_image_client.embed.call_args
|
||||
assert call_kwargs.kwargs["model"] == "image-model"
|
||||
|
||||
|
||||
class TestFoundryEmbeddingClient:
|
||||
"""Tests for the telemetry-enabled Foundry embedding client."""
|
||||
|
||||
async def test_text_embeddings(self, client: FoundryEmbeddingClient[Any], mock_text_client: AsyncMock) -> None:
|
||||
"""Text embeddings work through the telemetry layer."""
|
||||
result = await client.get_embeddings(["hello"])
|
||||
assert len(result) == 1
|
||||
assert result[0].vector == [0.1, 0.2, 0.3]
|
||||
|
||||
async def test_otel_provider_name_default(self) -> None:
|
||||
"""Default OTEL provider name is azure.ai.inference."""
|
||||
assert FoundryEmbeddingClient.OTEL_PROVIDER_NAME == "azure.ai.inference"
|
||||
|
||||
async def test_otel_provider_name_override(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None:
|
||||
"""OTEL provider name can be overridden."""
|
||||
client = FoundryEmbeddingClient(
|
||||
model="test-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
otel_provider_name="custom-provider",
|
||||
)
|
||||
assert client.otel_provider_name == "custom-provider"
|
||||
|
||||
|
||||
_SKIP_REASON = "Foundry inference integration tests disabled"
|
||||
|
||||
|
||||
def _foundry_integration_tests_enabled() -> bool:
|
||||
return bool(
|
||||
os.environ.get("FOUNDRY_MODELS_ENDPOINT")
|
||||
and os.environ.get("FOUNDRY_MODELS_API_KEY")
|
||||
and os.environ.get("FOUNDRY_EMBEDDING_MODEL")
|
||||
)
|
||||
|
||||
|
||||
skip_if_foundry_inference_integration_tests_disabled = pytest.mark.skipif(
|
||||
not _foundry_integration_tests_enabled(),
|
||||
reason=_SKIP_REASON,
|
||||
)
|
||||
|
||||
|
||||
class TestFoundryEmbeddingIntegration:
|
||||
"""Integration tests requiring a live Foundry inference endpoint."""
|
||||
|
||||
@pytest.mark.skip(reason="Flaky in merge queue, blocking unrelated PRs. Tracked in #5553.")
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_inference_integration_tests_disabled
|
||||
async def test_text_embedding_live(self) -> None:
|
||||
"""Generate text embeddings against a live endpoint."""
|
||||
client = FoundryEmbeddingClient()
|
||||
result = await client.get_embeddings(["Hello, world!"])
|
||||
assert len(result) == 1
|
||||
assert len(result[0].vector) > 0
|
||||
assert result[0].model is not None
|
||||
@@ -0,0 +1,503 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, Message
|
||||
from agent_framework._sessions import AgentSession, SessionContext
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
|
||||
from agent_framework_foundry._memory_provider import FoundryMemoryProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project_client() -> AsyncMock:
|
||||
"""Create a mock AIProjectClient."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.beta = AsyncMock()
|
||||
mock_client.beta.memory_stores = AsyncMock()
|
||||
mock_client.beta.memory_stores.search_memories = AsyncMock()
|
||||
mock_client.beta.memory_stores.begin_update_memories = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credential() -> Mock:
|
||||
"""Create a mock Azure credential."""
|
||||
return Mock()
|
||||
|
||||
|
||||
# -- Initialization tests ------------------------------------------------------
|
||||
|
||||
|
||||
def test_init_with_all_params(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
source_id="custom_source",
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
context_prompt="Custom prompt",
|
||||
update_delay=60,
|
||||
)
|
||||
assert provider.source_id == "custom_source"
|
||||
assert provider.project_client is mock_project_client
|
||||
assert provider.memory_store_name == "test_store"
|
||||
assert provider.scope == "user_123"
|
||||
assert provider.context_prompt == "Custom prompt"
|
||||
assert provider.update_delay == 60
|
||||
|
||||
|
||||
def test_init_default_source_id(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
assert provider.source_id == FoundryMemoryProvider.DEFAULT_SOURCE_ID
|
||||
|
||||
|
||||
def test_init_default_context_prompt(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
assert provider.context_prompt == FoundryMemoryProvider.DEFAULT_CONTEXT_PROMPT
|
||||
|
||||
|
||||
def test_init_default_update_delay(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
assert provider.update_delay == 300
|
||||
|
||||
|
||||
def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMock, mock_credential: Mock) -> None:
|
||||
with patch("agent_framework_foundry._memory_provider.AIProjectClient") as mock_ai_project_client:
|
||||
mock_ai_project_client.return_value = mock_project_client
|
||||
provider = FoundryMemoryProvider(
|
||||
project_endpoint="https://test.project.endpoint",
|
||||
credential=mock_credential, # type: ignore[arg-type]
|
||||
allow_preview=True,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
assert provider.project_client is mock_project_client
|
||||
mock_ai_project_client.assert_called_once_with(
|
||||
endpoint="https://test.project.endpoint",
|
||||
credential=mock_credential,
|
||||
allow_preview=True,
|
||||
user_agent=get_user_agent(),
|
||||
)
|
||||
|
||||
|
||||
def test_init_requires_project_endpoint_without_project_client() -> None:
|
||||
with (
|
||||
patch("agent_framework_foundry._memory_provider.load_settings") as mock_load_settings,
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
pytest.raises(ValueError, match="project endpoint is required"),
|
||||
):
|
||||
mock_load_settings.return_value = {"project_endpoint": None}
|
||||
FoundryMemoryProvider(
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
|
||||
|
||||
def test_init_requires_credential_without_project_client() -> None:
|
||||
with pytest.raises(ValueError, match="Azure credential is required"):
|
||||
FoundryMemoryProvider(
|
||||
project_endpoint="https://test.project.endpoint",
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
|
||||
|
||||
def test_init_requires_memory_store_name(mock_project_client: AsyncMock) -> None:
|
||||
with pytest.raises(ValueError, match="memory_store_name is required"):
|
||||
FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="",
|
||||
scope="user_123",
|
||||
)
|
||||
|
||||
|
||||
def test_init_requires_scope(mock_project_client: AsyncMock) -> None:
|
||||
with pytest.raises(ValueError, match="scope is required"):
|
||||
FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="",
|
||||
)
|
||||
|
||||
|
||||
# -- before_run tests ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_retrieves_static_memories_on_first_run(mock_project_client: AsyncMock) -> None:
|
||||
mem1 = Mock()
|
||||
mem1.memory_item.content = "User prefers Python"
|
||||
mem2 = Mock()
|
||||
mem2.memory_item.content = "User is based in Seattle"
|
||||
mock_search_result = Mock()
|
||||
mock_search_result.memories = [mem1, mem2]
|
||||
mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
# Should call search_memories twice: once for static, once for contextual
|
||||
assert mock_project_client.beta.memory_stores.search_memories.call_count == 2
|
||||
# Static memories should be cached
|
||||
assert len(session.state[provider.source_id]["static_memories"]) == 2
|
||||
assert session.state[provider.source_id]["initialized"] is True
|
||||
|
||||
|
||||
async def test_contextual_memories_added_to_context(mock_project_client: AsyncMock) -> None:
|
||||
# Mock static search (first call)
|
||||
static_mem = Mock()
|
||||
static_mem.memory_item.content = "User prefers Python"
|
||||
static_result = Mock()
|
||||
static_result.memories = [static_mem]
|
||||
|
||||
# Mock contextual search (second call)
|
||||
contextual_mem = Mock()
|
||||
contextual_mem.memory_item.content = "Last discussed async patterns"
|
||||
contextual_result = Mock()
|
||||
contextual_result.memories = [contextual_mem]
|
||||
contextual_result.search_id = "search-123"
|
||||
|
||||
mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result]
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
# Check that memories were added to context
|
||||
assert provider.source_id in ctx.context_messages
|
||||
added = ctx.context_messages[provider.source_id]
|
||||
assert len(added) == 1
|
||||
assert "User prefers Python" in added[0].text # type: ignore[operator]
|
||||
assert "Last discussed async patterns" in added[0].text # type: ignore[operator]
|
||||
assert provider.context_prompt in added[0].text # type: ignore[operator]
|
||||
assert session.state[provider.source_id]["previous_search_id"] == "search-123"
|
||||
|
||||
|
||||
async def test_empty_input_skips_contextual_search(mock_project_client: AsyncMock) -> None:
|
||||
static_result = Mock()
|
||||
static_result.memories = []
|
||||
mock_project_client.beta.memory_stores.search_memories.return_value = static_result
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=[""])], session_id="s1")
|
||||
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
# Should only call search_memories once for static memories
|
||||
assert mock_project_client.beta.memory_stores.search_memories.call_count == 1
|
||||
assert provider.source_id not in ctx.context_messages
|
||||
|
||||
|
||||
async def test_empty_search_results_no_messages(mock_project_client: AsyncMock) -> None:
|
||||
mock_search_result = Mock()
|
||||
mock_search_result.memories = []
|
||||
mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1")
|
||||
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
assert provider.source_id not in ctx.context_messages
|
||||
|
||||
|
||||
async def test_static_memories_only_retrieved_once(mock_project_client: AsyncMock) -> None:
|
||||
static_mem = Mock()
|
||||
static_mem.memory_item.content = "Static memory"
|
||||
static_result = Mock()
|
||||
static_result.memories = [static_mem]
|
||||
contextual_result = Mock()
|
||||
contextual_result.memories = []
|
||||
|
||||
mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result]
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
# First call
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
assert mock_project_client.beta.memory_stores.search_memories.call_count == 2
|
||||
|
||||
# Reset mock for second call
|
||||
mock_project_client.beta.memory_stores.search_memories.reset_mock()
|
||||
contextual_result2 = Mock()
|
||||
contextual_result2.memories = []
|
||||
mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2
|
||||
|
||||
# Second call - should only search contextual, not static
|
||||
ctx2 = SessionContext(input_messages=[Message(role="user", contents=["World"])], session_id="s1")
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
assert mock_project_client.beta.memory_stores.search_memories.call_count == 1
|
||||
|
||||
|
||||
async def test_handles_search_exception_gracefully(mock_project_client: AsyncMock) -> None:
|
||||
mock_project_client.beta.memory_stores.search_memories.side_effect = Exception("API error")
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
# Should not raise exception
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
# No memories added
|
||||
assert provider.source_id not in ctx.context_messages
|
||||
|
||||
|
||||
# -- after_run tests -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_stores_input_and_response(mock_project_client: AsyncMock) -> None:
|
||||
mock_poller = Mock()
|
||||
mock_poller.update_id = "update-456"
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["question"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["answer"])])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.assert_awaited_once()
|
||||
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
|
||||
assert call_kwargs["name"] == "test_store"
|
||||
assert call_kwargs["scope"] == "user_123"
|
||||
assert len(call_kwargs["items"]) == 2
|
||||
assert call_kwargs["items"][0]["content"] == "question"
|
||||
assert call_kwargs["items"][1]["content"] == "answer"
|
||||
assert session.state[provider.source_id]["previous_update_id"] == "update-456"
|
||||
|
||||
|
||||
async def test_only_stores_user_assistant_system(mock_project_client: AsyncMock) -> None:
|
||||
mock_poller = Mock()
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(
|
||||
input_messages=[
|
||||
Message(role="user", contents=["hello"]),
|
||||
Message(role="tool", contents=["tool output"]),
|
||||
],
|
||||
session_id="s1",
|
||||
)
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["reply"])])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
|
||||
items = call_kwargs["items"]
|
||||
assert len(items) == 2
|
||||
assert items[0]["content"] == "hello"
|
||||
assert items[1]["content"] == "reply"
|
||||
|
||||
|
||||
async def test_skips_empty_messages(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(
|
||||
input_messages=[
|
||||
Message(role="user", contents=[""]),
|
||||
Message(role="user", contents=[" "]),
|
||||
],
|
||||
session_id="s1",
|
||||
)
|
||||
ctx._response = AgentResponse(messages=[])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_uses_configured_update_delay(mock_project_client: AsyncMock) -> None:
|
||||
mock_poller = Mock()
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
update_delay=60,
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
|
||||
assert call_kwargs["update_delay"] == 60
|
||||
|
||||
|
||||
async def test_uses_previous_update_id_for_incremental_updates(mock_project_client: AsyncMock) -> None:
|
||||
mock_poller1 = Mock()
|
||||
mock_poller1.update_id = "update-1"
|
||||
mock_poller2 = Mock()
|
||||
mock_poller2.update_id = "update-2"
|
||||
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.side_effect = [mock_poller1, mock_poller2]
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx1 = SessionContext(input_messages=[Message(role="user", contents=["first"])], session_id="s1")
|
||||
ctx1._response = AgentResponse(messages=[Message(role="assistant", contents=["response1"])])
|
||||
|
||||
# First update
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx1, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
assert session.state[provider.source_id]["previous_update_id"] == "update-1"
|
||||
|
||||
# Second update should use previous_update_id
|
||||
ctx2 = SessionContext(input_messages=[Message(role="user", contents=["second"])], session_id="s1")
|
||||
ctx2._response = AgentResponse(messages=[Message(role="assistant", contents=["response2"])])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
|
||||
assert call_kwargs["previous_update_id"] == "update-1"
|
||||
assert session.state[provider.source_id]["previous_update_id"] == "update-2"
|
||||
|
||||
|
||||
async def test_handles_update_exception_gracefully(mock_project_client: AsyncMock) -> None:
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.side_effect = Exception("API error")
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])])
|
||||
|
||||
# Should not raise exception
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
|
||||
# -- Context manager tests -----------------------------------------------------
|
||||
|
||||
|
||||
async def test_aenter_delegates_to_client(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
result = await provider.__aenter__()
|
||||
assert result is provider
|
||||
mock_project_client.__aenter__.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_aexit_delegates_to_client(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
await provider.__aexit__(None, None, None)
|
||||
mock_project_client.__aexit__.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_async_with_syntax(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
async with provider as p:
|
||||
assert p is provider
|
||||
@@ -0,0 +1,164 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event
|
||||
|
||||
# region _validate_consent_link tests
|
||||
|
||||
|
||||
def test_validate_consent_link_accepts_valid_https() -> None:
|
||||
"""A valid HTTPS URL with a netloc passes validation."""
|
||||
link = "https://consent.example.com/auth?code=123"
|
||||
assert _validate_consent_link(link, "item-1") == link
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTP link is rejected and a warning is logged."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("http://insecure.example.com/login", "item-2")
|
||||
assert result == ""
|
||||
assert "non-HTTPS" in caplog.text
|
||||
assert "item-2" in caplog.text
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTPS URL with an empty netloc (e.g. https:///path) is rejected."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("https:///path", "item-3")
|
||||
assert result == ""
|
||||
assert "non-HTTPS" in caplog.text
|
||||
assert "item-3" in caplog.text
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A non-URL string is rejected."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("not-a-url", "item-4")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region try_parse_oauth_consent_event tests
|
||||
|
||||
|
||||
def _make_output_item_event(
|
||||
*,
|
||||
item_type: str = "oauth_consent_request",
|
||||
consent_link: Any = "https://consent.example.com/auth",
|
||||
item_id: str = "oauth-item-1",
|
||||
) -> MagicMock:
|
||||
"""Create a mock ``response.output_item.added`` event."""
|
||||
event = MagicMock()
|
||||
event.type = "response.output_item.added"
|
||||
item = MagicMock()
|
||||
item.type = item_type
|
||||
item.consent_link = consent_link
|
||||
item.id = item_id
|
||||
event.item = item
|
||||
return event
|
||||
|
||||
|
||||
def _make_top_level_event(
|
||||
*,
|
||||
consent_link: Any = "https://consent.example.com/authorize",
|
||||
event_id: str = "consent-event-1",
|
||||
) -> MagicMock:
|
||||
"""Create a mock ``response.oauth_consent_requested`` event."""
|
||||
event = MagicMock()
|
||||
event.type = "response.oauth_consent_requested"
|
||||
event.consent_link = consent_link
|
||||
event.id = event_id
|
||||
return event
|
||||
|
||||
|
||||
def test_returns_none_for_unrelated_event() -> None:
|
||||
"""An event with a non-oauth type returns None."""
|
||||
event = MagicMock()
|
||||
event.type = "response.output_text.delta"
|
||||
assert try_parse_oauth_consent_event(event, "model-x") is None
|
||||
|
||||
|
||||
def test_returns_none_for_event_without_type() -> None:
|
||||
"""An event object missing a 'type' attribute returns None."""
|
||||
event = object() # no type attribute
|
||||
assert try_parse_oauth_consent_event(event, "model-x") is None
|
||||
|
||||
|
||||
def test_parses_output_item_added_with_valid_link() -> None:
|
||||
"""A response.output_item.added event with a valid HTTPS link produces Content."""
|
||||
event = _make_output_item_event()
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert update.role == "assistant"
|
||||
assert update.model == "test-model"
|
||||
assert update.raw_representation is event
|
||||
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent) == 1
|
||||
assert consent[0].consent_link == "https://consent.example.com/auth"
|
||||
|
||||
|
||||
def test_parses_top_level_consent_requested_event() -> None:
|
||||
"""A response.oauth_consent_requested event produces Content."""
|
||||
event = _make_top_level_event()
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent) == 1
|
||||
assert consent[0].consent_link == "https://consent.example.com/authorize"
|
||||
|
||||
|
||||
def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A non-HTTPS consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "non-HTTPS" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A None consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link=None, item_id="item-none")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "without valid consent_link" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An empty-string consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link="", item_id="item-empty")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "without valid consent_link" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTPS URL with empty netloc (https:///path) is rejected."""
|
||||
event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "non-HTTPS" in caplog.text
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,664 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, MCPStdioTool, tool
|
||||
from agent_framework._feature_stage import ExperimentalFeature
|
||||
from azure.ai.projects.models import (
|
||||
CodeInterpreterTool,
|
||||
PromptAgentDefinition,
|
||||
PromptAgentDefinitionTextOptions,
|
||||
RaiConfig,
|
||||
Reasoning,
|
||||
StructuredInputDefinition,
|
||||
ToolChoiceAllowed,
|
||||
ToolChoiceFunction,
|
||||
WebSearchTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
FunctionTool as ProjectsFunctionTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
MCPTool as FoundryMCPTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
Tool as ProjectsTool,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_foundry import (
|
||||
FoundryChatClient,
|
||||
RawFoundryChatClient,
|
||||
to_prompt_agent,
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: Annotated[str, "City name"]) -> str:
|
||||
"""Get the weather for a location."""
|
||||
return f"sunny in {location}"
|
||||
|
||||
|
||||
def _make_foundry_chat_client(model: str | None = "gpt-4o-mini") -> FoundryChatClient:
|
||||
"""Build a FoundryChatClient backed by a mocked project client."""
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
return FoundryChatClient(project_client=mock_project, model=model or "placeholder")
|
||||
|
||||
|
||||
def _make_agent(client: Any, **agent_kwargs: Any) -> Agent:
|
||||
"""Build an Agent without entering the async context manager."""
|
||||
return Agent(client=client, **agent_kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core conversion: model resolution and client-type guarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_minimal() -> None:
|
||||
"""An agent with only model + instructions produces a valid PromptAgentDefinition."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="Be helpful.")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition, PromptAgentDefinition)
|
||||
assert definition.model == "gpt-4o-mini"
|
||||
assert definition.instructions == "Be helpful."
|
||||
assert definition.tools is None
|
||||
|
||||
|
||||
def test_to_prompt_agent_serializes_cleanly() -> None:
|
||||
"""The PromptAgentDefinition serializes to a dict that includes ``kind: prompt``."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="Hi.")
|
||||
|
||||
payload = to_prompt_agent(agent).as_dict()
|
||||
|
||||
assert payload["model"] == "gpt-4o-mini"
|
||||
assert payload["instructions"] == "Hi."
|
||||
assert payload["kind"] == "prompt"
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_non_foundry_client() -> None:
|
||||
"""A non-FoundryChatClient client raises TypeError."""
|
||||
|
||||
class NotFoundryChatClient:
|
||||
"""Stand-in for a different chat client implementation."""
|
||||
|
||||
agent = _make_agent(NotFoundryChatClient())
|
||||
|
||||
with pytest.raises(TypeError, match="FoundryChatClient"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_missing_model() -> None:
|
||||
"""When neither default_options nor the client has a model, ValueError is raised."""
|
||||
client = _make_foundry_chat_client()
|
||||
client.model = ""
|
||||
agent = _make_agent(client)
|
||||
agent.default_options.pop("model", None)
|
||||
|
||||
with pytest.raises(ValueError, match="Agent has no model"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_no_instructions() -> None:
|
||||
"""A tool-only agent (no instructions) produces a definition with instructions=None."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
tools=[WebSearchTool()],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "gpt-4o-mini"
|
||||
assert definition.instructions is None
|
||||
payload = definition.as_dict()
|
||||
assert "instructions" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_prefers_default_options_model() -> None:
|
||||
"""default_options['model'] wins over the bound client's model."""
|
||||
client = _make_foundry_chat_client(model="client-model")
|
||||
agent = _make_agent(client, instructions="x", default_options={"model": "agent-override"})
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "agent-override"
|
||||
|
||||
|
||||
def test_to_prompt_agent_falls_back_to_client_model() -> None:
|
||||
"""When the agent has no model override, the bound client's model is used."""
|
||||
agent = _make_agent(_make_foundry_chat_client(model="client-model"), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "client-model"
|
||||
|
||||
|
||||
def test_to_prompt_agent_works_with_raw_foundry_chat_client() -> None:
|
||||
"""to_prompt_agent accepts subclasses too — RawFoundryChatClient works."""
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
raw_client = RawFoundryChatClient(project_client=mock_project, model="gpt-4o")
|
||||
agent = _make_agent(raw_client, instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "gpt-4o"
|
||||
|
||||
|
||||
def test_to_prompt_agent_is_marked_experimental() -> None:
|
||||
"""to_prompt_agent carries the TO_PROMPT_AGENT experimental metadata."""
|
||||
assert getattr(to_prompt_agent, "__feature_stage__", None) == "experimental"
|
||||
assert getattr(to_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value
|
||||
|
||||
|
||||
def test_to_prompt_agent_does_not_mutate_default_options() -> None:
|
||||
"""Conversion never mutates the translatable option values in ``agent.default_options``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.5,
|
||||
"reasoning": {"effort": "low"},
|
||||
"response_format": {"type": "json_object"},
|
||||
"verbosity": "low",
|
||||
},
|
||||
tools=[get_weather],
|
||||
)
|
||||
reasoning_before = dict(agent.default_options["reasoning"]) # type: ignore[index]
|
||||
response_format_before = dict(agent.default_options["response_format"]) # type: ignore[index]
|
||||
tool_choice_before = agent.default_options.get("tool_choice")
|
||||
|
||||
to_prompt_agent(agent)
|
||||
|
||||
assert dict(agent.default_options["reasoning"]) == reasoning_before # type: ignore[index]
|
||||
assert dict(agent.default_options["response_format"]) == response_format_before # type: ignore[index]
|
||||
assert agent.default_options.get("tool_choice") == tool_choice_before
|
||||
assert "text" not in agent.default_options
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_sdk_tool_instances() -> None:
|
||||
"""Foundry SDK tool instances (e.g. WebSearchTool) are passed through unchanged."""
|
||||
ws = WebSearchTool()
|
||||
ci = CodeInterpreterTool({"container": {"type": "auto"}})
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[ws, ci])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 2
|
||||
assert definition.tools[0] is ws
|
||||
assert definition.tools[1] is ci
|
||||
|
||||
|
||||
def test_to_prompt_agent_converts_function_tool() -> None:
|
||||
"""An AF FunctionTool from @tool emerges as a Foundry FunctionTool declaration."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
fn = definition.tools[0]
|
||||
assert isinstance(fn, ProjectsFunctionTool)
|
||||
assert fn.name == "get_weather"
|
||||
assert fn.description == "Get the weather for a location."
|
||||
assert fn.strict is True
|
||||
parameters = fn.parameters
|
||||
assert parameters["type"] == "object"
|
||||
assert "location" in parameters["properties"]
|
||||
assert parameters["required"] == ["location"]
|
||||
|
||||
|
||||
def test_to_prompt_agent_preserves_mixed_tool_order() -> None:
|
||||
"""A mix of hosted SDK tools and function tools is preserved in definition order."""
|
||||
ws = WebSearchTool()
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[ws, get_weather],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert definition.tools[0] is ws
|
||||
assert isinstance(definition.tools[1], ProjectsFunctionTool)
|
||||
assert definition.tools[1].name == "get_weather"
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_hosted_mcp_tool() -> None:
|
||||
"""A hosted MCP tool from FoundryChatClient.get_mcp_tool() is passed through."""
|
||||
hosted_mcp = FoundryChatClient.get_mcp_tool(
|
||||
name="github",
|
||||
url="https://mcp.example.com",
|
||||
)
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[hosted_mcp])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
assert isinstance(definition.tools[0], FoundryMCPTool)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_local_mcp_tool() -> None:
|
||||
"""A local MCP tool in agent.mcp_tools raises a ValueError pointing at get_mcp_tool."""
|
||||
local_mcp = MCPStdioTool(name="local_fs", command="echo")
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[local_mcp])
|
||||
|
||||
with pytest.raises(ValueError, match="get_mcp_tool"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_unknown_tool_type() -> None:
|
||||
"""An arbitrary object in tools that isn't a known shape raises ValueError."""
|
||||
|
||||
class NotATool:
|
||||
pass
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[NotATool()],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="NotATool"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_accepts_dict_tool() -> None:
|
||||
"""A dict with a 'type' discriminator is rehydrated through the SDK Tool model."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[{"type": "web_search"}],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
tool_obj = definition.tools[0]
|
||||
# The SDK discriminator on ``type`` should materialize the concrete subclass
|
||||
# (here ``WebSearchTool``), not a generic ``Tool``.
|
||||
assert isinstance(tool_obj, WebSearchTool)
|
||||
assert isinstance(tool_obj, ProjectsTool)
|
||||
assert tool_obj.type == "web_search"
|
||||
|
||||
|
||||
def test_to_prompt_agent_accepts_dict_function_tool() -> None:
|
||||
"""A dict with ``type='function'`` rehydrates to a Foundry ``FunctionTool``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Look up a value.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
tool_obj = definition.tools[0]
|
||||
assert isinstance(tool_obj, ProjectsFunctionTool)
|
||||
assert tool_obj.name == "lookup"
|
||||
assert tool_obj.description == "Look up a value."
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_dict_tool_without_type() -> None:
|
||||
"""A dict missing the 'type' field raises ValueError."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[{"name": "missing_type"}],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="type"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generation parameters sourced from default_options
|
||||
# (translated by _prepare_prompt_agent_options in _to_prompt_agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_temperature_top_p_unset_by_default() -> None:
|
||||
"""Without default_options entries, temperature/top_p are unset on the definition."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature is None
|
||||
assert definition.top_p is None
|
||||
payload = definition.as_dict()
|
||||
assert "temperature" not in payload
|
||||
assert "top_p" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_temperature_top_p_from_default_options() -> None:
|
||||
"""temperature/top_p in default_options flow through to the definition."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"temperature": 0.42, "top_p": 0.8},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature == 0.42
|
||||
assert definition.top_p == 0.8
|
||||
|
||||
|
||||
def test_to_prompt_agent_temperature_zero_is_honored() -> None:
|
||||
"""A literal ``0.0`` in default_options is treated as explicit, not as unset."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"temperature": 0.0, "top_p": 0.0},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature == 0.0
|
||||
assert definition.top_p == 0.0
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_omitted_when_no_tools() -> None:
|
||||
"""``tool_choice`` is dropped when the definition has no tools.
|
||||
|
||||
Mirrors RawOpenAIChatClient._prepare_options behavior. This also keeps
|
||||
Agent.__init__'s default ``tool_choice="auto"`` from polluting tool-less
|
||||
prompt agents.
|
||||
"""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice is None
|
||||
assert "tool_choice" not in definition.as_dict()
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_auto_with_tools() -> None:
|
||||
"""When tools are present, the default ``tool_choice="auto"`` flows through."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice == "auto"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_required_string_with_tools() -> None:
|
||||
"""A string ``tool_choice="required"`` flows through when tools are present."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={"tool_choice": "required"},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice == "required"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_required_function_dict() -> None:
|
||||
"""tool_choice mode=required with a function name → ToolChoiceFunction."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"tool_choice": {"mode": "required", "required_function_name": "get_weather"},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.tool_choice, ToolChoiceFunction)
|
||||
assert definition.tool_choice.name == "get_weather"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_auto_allowed_tools() -> None:
|
||||
"""tool_choice mode=auto with allowed_tools → ToolChoiceAllowed."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.tool_choice, ToolChoiceAllowed)
|
||||
assert definition.tool_choice.mode == "auto"
|
||||
assert definition.tool_choice.tools == [{"type": "function", "name": "get_weather"}]
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_reasoning_dict_from_default_options() -> None:
|
||||
"""A reasoning dict in default_options becomes a Foundry ``Reasoning`` model."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"reasoning": {"effort": "high", "summary": "concise"}},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.reasoning, Reasoning)
|
||||
assert definition.reasoning.effort == "high"
|
||||
assert definition.reasoning.summary == "concise"
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_reasoning_model_from_default_options() -> None:
|
||||
"""A pre-built ``Reasoning`` model in default_options is passed through."""
|
||||
reasoning = Reasoning(effort="medium")
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"reasoning": reasoning},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.reasoning is reasoning
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_response_format_dict_to_text() -> None:
|
||||
"""A ``response_format`` dict in default_options becomes ``text.format``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "weather",
|
||||
"schema": {"type": "object", "properties": {"temp": {"type": "number"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
format_dict = definition.text["format"]
|
||||
assert format_dict is not None
|
||||
assert format_dict["type"] == "json_schema"
|
||||
assert format_dict["name"] == "weather"
|
||||
assert format_dict["schema"] == {"type": "object", "properties": {"temp": {"type": "number"}}}
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_response_format_pydantic_to_text() -> None:
|
||||
"""A Pydantic ``BaseModel`` response_format becomes ``text.format`` json_schema."""
|
||||
|
||||
class WeatherReply(BaseModel):
|
||||
location: str
|
||||
condition: str
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"response_format": WeatherReply},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
format_dict = definition.text["format"]
|
||||
assert format_dict is not None
|
||||
assert format_dict["type"] == "json_schema"
|
||||
assert format_dict["name"] == "WeatherReply"
|
||||
assert "schema" in format_dict
|
||||
assert "location" in format_dict["schema"]["properties"]
|
||||
|
||||
|
||||
def test_to_prompt_agent_merges_verbosity_into_text() -> None:
|
||||
"""A ``verbosity`` entry merges into the ``text`` config."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"verbosity": "low"},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
# PromptAgentDefinitionTextOptions only declares ``format``, but its
|
||||
# mapping-init preserves extra keys for server-side use.
|
||||
assert dict(definition.text).get("verbosity") == "low"
|
||||
|
||||
|
||||
def test_to_prompt_agent_raises_on_conflicting_response_format_and_text_format() -> None:
|
||||
"""Pydantic ``response_format`` + a different ``text.format`` mapping must fail loudly."""
|
||||
|
||||
class WeatherReply(BaseModel):
|
||||
location: str
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"response_format": WeatherReply,
|
||||
"text": {"format": {"type": "json_object"}},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Conflicting response_format"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_text_dict_from_default_options() -> None:
|
||||
"""A ``text`` dict in default_options flows through to the definition."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"text": {"format": {"type": "text"}, "verbosity": "high"}},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
assert definition.text["format"] == {"type": "text"}
|
||||
assert dict(definition.text).get("verbosity") == "high"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Foundry-specific kwargs (no AF ChatOptions equivalent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_kwarg_only_fields_unset_by_default() -> None:
|
||||
"""structured_inputs and rai_config are absent from the payload when unset."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
payload = to_prompt_agent(agent).as_dict()
|
||||
|
||||
assert "structured_inputs" not in payload
|
||||
assert "rai_config" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_forwards_structured_inputs_kwarg() -> None:
|
||||
"""A ``structured_inputs`` mapping is forwarded (and copied to a new dict)."""
|
||||
inputs = {"city": StructuredInputDefinition(description="Target city.")}
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent, structured_inputs=inputs)
|
||||
|
||||
assert definition.structured_inputs is not None
|
||||
assert set(definition.structured_inputs) == {"city"}
|
||||
assert definition.structured_inputs["city"] is inputs["city"]
|
||||
inputs["other"] = StructuredInputDefinition(description="x")
|
||||
assert "other" not in definition.structured_inputs
|
||||
|
||||
|
||||
def test_to_prompt_agent_forwards_rai_config_kwarg() -> None:
|
||||
"""A ``RaiConfig`` kwarg is forwarded to the definition."""
|
||||
rai_config = RaiConfig(rai_policy_name="test-policy")
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent, rai_config=rai_config)
|
||||
|
||||
assert definition.rai_config is rai_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Combined integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_combines_all_sources() -> None:
|
||||
"""Generation params from default_options + Foundry-only kwargs combine cleanly."""
|
||||
rai_config = RaiConfig(rai_policy_name="test-policy")
|
||||
structured = {"q": StructuredInputDefinition(description="query")}
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.95,
|
||||
"tool_choice": "auto",
|
||||
"reasoning": {"effort": "medium"},
|
||||
"verbosity": "low",
|
||||
},
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(
|
||||
agent,
|
||||
structured_inputs=structured,
|
||||
rai_config=rai_config,
|
||||
)
|
||||
|
||||
assert definition.temperature == 0.3
|
||||
assert definition.top_p == 0.95
|
||||
assert definition.tool_choice == "auto"
|
||||
assert isinstance(definition.reasoning, Reasoning)
|
||||
assert definition.reasoning.effort == "medium"
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
assert dict(definition.text).get("verbosity") == "low"
|
||||
assert definition.rai_config is rai_config
|
||||
assert definition.structured_inputs is not None and "q" in definition.structured_inputs
|
||||
assert definition.tools is not None and len(definition.tools) == 1
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user