Merge branch 'main' into feature-python-foundry-agents

This commit is contained in:
Chris
2025-11-07 10:20:41 -08:00
committed by GitHub
Unverified
158 changed files with 9985 additions and 1770 deletions
+6
View File
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b251106.post1] - 2025-11-06
### Fixed
- **agent-framework-ag-ui**: Fix ag-ui examples packaging for PyPI publish ([#1953](https://github.com/microsoft/agent-framework/pull/1953))
## [1.0.0b251106] - 2025-11-06
### Changed
+2 -2
View File
@@ -36,7 +36,7 @@ add_agent_framework_fastapi_endpoint(app, agent, "/")
## Documentation
- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building your first AG-UI server and client
- **[Examples](examples/)** - Complete examples for AG-UI features
- **[Examples](agent_framework_ag_ui_examples/)** - Complete examples for AG-UI features
## Features
@@ -64,7 +64,7 @@ The package uses a clean, orchestrator-based architecture:
## Next Steps
1. **New to AG-UI?** Start with the [Getting Started Tutorial](getting_started/)
2. **Want to see examples?** Check out the [Examples](examples/) for AG-UI features
2. **Want to see examples?** Check out the [Examples](agent_framework_ag_ui_examples/) for AG-UI features
## License
@@ -629,7 +629,7 @@ Now that you understand the basics of AG-UI, you can:
## Additional Resources
- [AG-UI Examples](../examples/README.md): Complete working examples for all 7 features
- [AG-UI Examples](../agent_framework_ag_ui_examples/README.md): Complete working examples for all 7 features
- [Agent Framework Documentation](../../core/README.md): Learn more about creating agents
- [AG-UI Protocol Spec](https://docs.ag-ui.com/): Official protocol documentation
+2 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b251106"
version = "1.0.0b251106.post1"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -40,8 +40,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["agent_framework_ag_ui"]
force-include = { "examples" = "agent_framework_ag_ui_examples" }
packages = ["agent_framework_ag_ui", "agent_framework_ag_ui_examples"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
@@ -14,9 +14,11 @@ _IMPORTS: dict[str, tuple[str, list[str]]] = {
"PurviewAppLocation": ("agent_framework_purview", ["microsoft-purview", "purview"]),
"PurviewLocationType": ("agent_framework_purview", ["microsoft-purview", "purview"]),
"PurviewAuthenticationError": ("agent_framework_purview", ["microsoft-purview", "purview"]),
"PurviewPaymentRequiredError": ("agent_framework_purview", ["microsoft-purview", "purview"]),
"PurviewRateLimitError": ("agent_framework_purview", ["microsoft-purview", "purview"]),
"PurviewRequestError": ("agent_framework_purview", ["microsoft-purview", "purview"]),
"PurviewServiceError": ("agent_framework_purview", ["microsoft-purview", "purview"]),
"CacheProvider": ("agent_framework_purview", ["microsoft-purview", "purview"]),
}
@@ -2,10 +2,12 @@
from agent_framework_copilotstudio import CopilotStudioAgent, __version__, acquire_token
from agent_framework_purview import (
CacheProvider,
PurviewAppLocation,
PurviewAuthenticationError,
PurviewChatPolicyMiddleware,
PurviewLocationType,
PurviewPaymentRequiredError,
PurviewPolicyMiddleware,
PurviewRateLimitError,
PurviewRequestError,
@@ -14,11 +16,13 @@ from agent_framework_purview import (
)
__all__ = [
"CacheProvider",
"CopilotStudioAgent",
"PurviewAppLocation",
"PurviewAuthenticationError",
"PurviewChatPolicyMiddleware",
"PurviewLocationType",
"PurviewPaymentRequiredError",
"PurviewPolicyMiddleware",
"PurviewRateLimitError",
"PurviewRequestError",
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
const [activeTab, setActiveTab] = useState<Tab>("settings");
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
const [backendUrl, setBackendUrl] = useState(() => {
return localStorage.getItem("devui_backend_url") || defaultUrl;
});
@@ -61,7 +61,7 @@ interface ConversationApiResponse {
const DEFAULT_API_BASE_URL =
import.meta.env.VITE_API_BASE_URL !== undefined
? import.meta.env.VITE_API_BASE_URL
: "http://localhost:8080";
: ""; // Default to relative URLs (same host as frontend)
// Retry configuration for streaming
const RETRY_INTERVAL_MS = 1000; // Retry every second
@@ -72,12 +72,6 @@ function getBackendUrl(): string {
const stored = localStorage.getItem("devui_backend_url");
if (stored) return stored;
// If VITE_API_BASE_URL is explicitly set to empty string, use relative path
// This allows the frontend to call the same host it's served from
if (import.meta.env.VITE_API_BASE_URL === "") {
return "";
}
return DEFAULT_API_BASE_URL;
}
+149 -48
View File
@@ -10,20 +10,45 @@
- Blocks or allows content at both ingress (prompt) and egress (response)
- Works with any `ChatAgent` / agent orchestration using the standard Agent Framework middleware pipeline
- Supports both synchronous `TokenCredential` and `AsyncTokenCredential` from `azure-identity`
- Simple, typed configuration via `PurviewSettings` / `PurviewAppLocation`
- Two middleware types:
- `PurviewPolicyMiddleware` (Agent pipeline)
- `PurviewChatPolicyMiddleware` (Chat client middleware list)
- Configuration via `PurviewSettings` / `PurviewAppLocation`
- Built-in caching with configurable TTL and size limits for protection scopes in `PurviewSettings`
- Background processing for content activities and offline policy evaluation
### When to Use
Add Purview when you need to:
- **Prevent sensitive data leaks**: Inline blocking of sensitive content based on Data Loss Prevention (DLP) policies.
- **Enable governance**: Log AI interactions in Purview for Audit, Communication Compliance, Insider Risk Management, eDiscovery, and Data Lifecycle Management.
- Prevent sensitive or disallowed content from being sent to an LLM
- Prevent model output containing disallowed data from leaving the system
- Apply centrally managed policies without rewriting agent logic
---
## Prerequisites
- Microsoft Azure subscription with Microsoft Purview configured.
- Microsoft 365 subscription with an E5 license and pay-as-you-go billing setup.
- For testing, you can use a Microsoft 365 Developer Program tenant. For more information, see [Join the Microsoft 365 Developer Program](https://developer.microsoft.com/en-us/microsoft-365/dev-program).
### Authentication
`PurviewClient` uses the `azure-identity` library for token acquisition. You can use any `TokenCredential` or `AsyncTokenCredential` implementation.
- **Entra registration**: Register your agent and add the required Microsoft Graph permissions (`dataSecurityAndGovernance`) to the Service Principal. For more information, see [Register an application in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) and [dataSecurityAndGovernance resource type](https://learn.microsoft.com/en-us/graph/api/resources/datasecurityandgovernance). You'll need the Microsoft Entra app ID in the next step.
- **Graph Permissions**:
- ProtectionScopes.Compute.All : [userProtectionScopeContainer](https://learn.microsoft.com/en-us/graph/api/userprotectionscopecontainer-compute)
- Content.Process.All : [processContent](https://learn.microsoft.com/en-us/graph/api/userdatasecurityandgovernance-processcontent)
- ContentActivity.Write : [contentActivity](https://learn.microsoft.com/en-us/graph/api/activitiescontainer-post-contentactivities)
- **Purview policies**: Configure Purview policies using the Microsoft Entra app ID to enable agent communications data to flow into Purview. For more information, see [Configure Microsoft Purview](https://learn.microsoft.com/purview/developer/configurepurview).
#### Scopes
`PurviewSettings.get_scopes()` derives the Graph scope list (currently `https://graph.microsoft.com/.default` style).
---
## Quick Start
```python
@@ -57,37 +82,75 @@ If a policy violation is detected on the prompt, the middleware terminates the r
---
## Authentication
`PurviewClient` uses the `azure-identity` library for token acquisition. You can use any `TokenCredential` or `AsyncTokenCredential` implementation.
The APIs require the following Graph Permissions:
- ProtectionScopes.Compute.All : [userProtectionScopeContainer](https://learn.microsoft.com/en-us/graph/api/userprotectionscopecontainer-compute)
- Content.Process.All : [processContent](https://learn.microsoft.com/en-us/graph/api/userdatasecurityandgovernance-processcontent)
- ContentActivity.Write : [contentActivity](https://learn.microsoft.com/en-us/graph/api/activitiescontainer-post-contentactivities)
### Scopes
`PurviewSettings.get_scopes()` derives the Graph scope list (currently `https://graph.microsoft.com/.default` style).
### Tenant Enablement for Purview
- The tenant requires an e5 license and consumptive billing setup.
- There need to be [Data Loss Prevention](https://learn.microsoft.com/en-us/purview/dlp-create-deploy-policy) or [Data Collection Policies](https://learn.microsoft.com/en-us/purview/collection-policies-policy-reference) that apply to the user to call Process Content API else it calls Content Activities API for auditing the message.
---
## Configuration
### `PurviewSettings`
```python
PurviewSettings(
app_name="My App", # Display / logical name
tenant_id=None, # Optional used mainly for auth context
purview_app_location=None, # Optional PurviewAppLocation for scoping
app_name="My App", # Required: Display / logical name
app_version=None, # Optional: Version string of the application
tenant_id=None, # Optional: Tenant id (guid), used mainly for auth context
purview_app_location=None, # Optional: PurviewAppLocation for scoping
graph_base_uri="https://graph.microsoft.com/v1.0/",
process_inline=False, # Reserved for future inline processing optimizations
blocked_prompt_message="Prompt blocked by policy", # Custom message for blocked prompts
blocked_response_message="Response blocked by policy" # Custom message for blocked responses
blocked_prompt_message="Prompt blocked by policy", # Custom message for blocked prompts
blocked_response_message="Response blocked by policy", # Custom message for blocked responses
ignore_exceptions=False, # If True, non-payment exceptions are logged but not thrown
ignore_payment_required=False, # If True, 402 payment required errors are logged but not thrown
cache_ttl_seconds=14400, # Cache TTL in seconds (default 4 hours)
max_cache_size_bytes=200 * 1024 * 1024 # Max cache size in bytes (default 200MB)
)
```
### Caching
The Purview integration includes built-in caching for protection scopes responses to improve performance and reduce API calls:
- **Default TTL**: 4 hours (14400 seconds)
- **Default Cache Size**: 200MB
- **Cache Provider**: `InMemoryCacheProvider` is used by default, but you can provide a custom implementation via the `CacheProvider` protocol
- **Cache Invalidation**: Cache is automatically invalidated when protection scope state is modified
- **Exception Caching**: 402 Payment Required errors are cached to avoid repeated failed API calls
You can customize caching behavior in `PurviewSettings`:
```python
from agent_framework.microsoft import PurviewSettings
settings = PurviewSettings(
app_name="My App",
cache_ttl_seconds=14400, # 4 hours
max_cache_size_bytes=200 * 1024 * 1024 # 200MB
)
```
Or provide your own cache provider:
```python
from typing import Any
from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings, CacheProvider
from azure.identity import DefaultAzureCredential
class MyCustomCache(CacheProvider):
async def get(self, key: str) -> Any | None:
# Your implementation
pass
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
# Your implementation
pass
async def remove(self, key: str) -> None:
# Your implementation
pass
credential = DefaultAzureCredential()
settings = PurviewSettings(app_name="MyApp")
middleware = PurviewPolicyMiddleware(
credential=credential,
settings=settings,
cache_provider=MyCustomCache()
)
```
@@ -123,11 +186,32 @@ settings = PurviewSettings(
)
```
This is useful for:
- Providing more user-friendly error messages
- Including support contact information
- Localizing messages for different languages
- Adding branding or specific guidance for your application
### Exception Handling Controls
The Purview integration provides fine-grained control over exception handling to support graceful degradation scenarios:
```python
from agent_framework.microsoft import PurviewSettings
# Ignore all non-payment exceptions (continue execution even if policy check fails)
settings = PurviewSettings(
app_name="My App",
ignore_exceptions=True # Log errors but don't throw
)
# Ignore only 402 Payment Required errors (useful for tenants without proper licensing)
settings = PurviewSettings(
app_name="My App",
ignore_payment_required=True # Continue even without Purview Consumptive Billing Setup
)
# Both can be combined
settings = PurviewSettings(
app_name="My App",
ignore_exceptions=True,
ignore_payment_required=True
)
```
### Selecting Agent vs Chat Middleware
@@ -178,12 +262,17 @@ The policy logic is identical; the difference is only the hook point in the pipe
## Middleware Lifecycle
1. Before agent execution (`prompt phase`): all `context.messages` are evaluated.
2. If blocked: `context.result` is replaced with a system message and `context.terminate = True`.
3. After successful agent execution (`response phase`): the produced messages are evaluated.
4. If blocked: result messages are replaced with a blocking notice.
1. **Before agent execution** (`prompt phase`): all `context.messages` are evaluated.
- If no valid user_id is found, processing is skipped (no policy evaluation)
- Protection scopes are retrieved (with caching)
- Applicable scopes are checked to determine execution mode
- In inline mode: content is evaluated immediately
- In offline mode: evaluation is queued in background
2. **If blocked**: `context.result` is replaced with a system message and `context.terminate = True`.
3. **After successful agent execution** (`response phase`): the produced messages are evaluated using the same user_id from the prompt phase.
4. **If blocked**: result messages are replaced with a blocking notice.
When a user identifier is discovered (e.g. in `ChatMessage.additional_properties['user_id']`) during the prompt phase it is reused for the response phase so both evaluations map consistently to the same user.
The user identifier is discovered from `ChatMessage.additional_properties['user_id']` during the prompt phase and reused for the response phase, ensuring both evaluations map consistently to the same user. If no user_id is present, policy evaluation is skipped entirely.
You can customize the blocking messages using the `blocked_prompt_message` and `blocked_response_message` fields in `PurviewSettings`. For more advanced scenarios, you can wrap the middleware or post-process `context.result` in later middleware.
@@ -193,32 +282,44 @@ You can customize the blocking messages using the `blocked_prompt_message` and `
| Exception | Scenario |
|-----------|----------|
| `PurviewPaymentRequiredError` | 402 Payment Required - tenant lacks proper Purview licensing or consumptive billing setup |
| `PurviewAuthenticationError` | Token acquisition / validation issues |
| `PurviewRateLimitError` | 429 responses from service |
| `PurviewRequestError` | 4xx client errors (bad input, unauthorized, forbidden) |
| `PurviewServiceError` | 5xx or unexpected service errors |
Catch broadly if you want unified fallback:
### Exception Handling
All exceptions inherit from `PurviewServiceError`. You can catch specific exceptions or use the base class:
```python
from agent_framework.microsoft import (
PurviewAuthenticationError, PurviewRateLimitError,
PurviewRequestError, PurviewServiceError
PurviewPaymentRequiredError,
PurviewAuthenticationError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError
)
try:
...
# Your code here
pass
except PurviewPaymentRequiredError as ex:
# Handle licensing issues specifically
print(f"Purview licensing required: {ex}")
except (PurviewAuthenticationError, PurviewRateLimitError, PurviewRequestError, PurviewServiceError) as ex:
# Log / degrade gracefully
print(f"Purview enforcement skipped: {ex}")
# Handle other errors
print(f"Purview enforcement skipped: {ex}")
```
---
## Notes
- Provide a `user_id` per request (e.g. in `ChatMessage(..., additional_properties={"user_id": "<guid>"})`) when possible for per-user policy scoping; otherwise supply a default via settings or environment.
- Blocking messages can be customized via `blocked_prompt_message` and `blocked_response_message` in `PurviewSettings`. By default, they are "Prompt blocked by policy" and "Response blocked by policy" respectively.
- Streaming responses: post-response policy evaluation presently applies only to non-streaming chat responses.
- Errors during policy checks are logged and do not fail the run; they degrade gracefully.
- **User Identification**: Provide a `user_id` per request (e.g. in `ChatMessage(..., additional_properties={"user_id": "<guid>"})`) for per-user policy scoping. If no user_id is provided, policy evaluation is skipped entirely.
- **Blocking Messages**: Can be customized via `blocked_prompt_message` and `blocked_response_message` in `PurviewSettings`. By default, they are "Prompt blocked by policy" and "Response blocked by policy" respectively.
- **Streaming Responses**: Post-response policy evaluation presently applies only to non-streaming chat responses.
- **Error Handling**: Use `ignore_exceptions` and `ignore_payment_required` settings for graceful degradation. When enabled, errors are logged but don't fail the request.
- **Caching**: Protection scopes responses and 402 errors are cached by default with a 4-hour TTL. Cache is automatically invalidated when protection scope state changes.
- **Background Processing**: Content Activities and offline Process Content requests are handled asynchronously using background tasks to avoid blocking the main execution flow.
@@ -1,7 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from ._cache import CacheProvider
from ._exceptions import (
PurviewAuthenticationError,
PurviewPaymentRequiredError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
@@ -10,10 +12,12 @@ from ._middleware import PurviewChatPolicyMiddleware, PurviewPolicyMiddleware
from ._settings import PurviewAppLocation, PurviewLocationType, PurviewSettings
__all__ = [
"CacheProvider",
"PurviewAppLocation",
"PurviewAuthenticationError",
"PurviewChatPolicyMiddleware",
"PurviewLocationType",
"PurviewPaymentRequiredError",
"PurviewPolicyMiddleware",
"PurviewRateLimitError",
"PurviewRequestError",
@@ -0,0 +1,191 @@
# Copyright (c) Microsoft. All rights reserved.
"""Cache provider for Purview data."""
from __future__ import annotations
import hashlib
import heapq
import json
import sys
import time
from typing import Any, Protocol
from ._models import ProtectionScopesRequest
class CacheProvider(Protocol):
"""Protocol for cache providers used by Purview integration."""
async def get(self, key: str) -> Any | None:
"""Get a value from the cache.
Args:
key: The cache key.
Returns:
The cached value or None if not found or expired.
"""
...
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
"""Set a value in the cache.
Args:
key: The cache key.
value: The value to cache.
ttl_seconds: Time to live in seconds. If None, uses provider default.
"""
...
async def remove(self, key: str) -> None:
"""Remove a value from the cache.
Args:
key: The cache key.
"""
...
class InMemoryCacheProvider:
"""Simple in-memory cache implementation for Purview data.
This implementation uses a dictionary with expiration tracking and size limits.
"""
def __init__(self, default_ttl_seconds: int = 1800, max_size_bytes: int = 200 * 1024 * 1024):
"""Initialize the in-memory cache.
Args:
default_ttl_seconds: Default time to live in seconds (default 1800 = 30 minutes).
max_size_bytes: Maximum cache size in bytes (default 200MB).
"""
self._cache: dict[str, tuple[Any, float, int]] = {} # key -> (value, expiry, size)
self._expiry_heap: list[tuple[float, str]] = [] # min-heap of (expiry_time, key)
self._default_ttl = default_ttl_seconds
self._max_size_bytes = max_size_bytes
self._current_size_bytes = 0
def _estimate_size(self, value: Any) -> int:
"""Estimate the size of a cached value in bytes.
Args:
value: The value to estimate size for.
Returns:
Estimated size in bytes.
"""
try:
if hasattr(value, "model_dump_json"):
return len(value.model_dump_json().encode("utf-8"))
return len(json.dumps(value, default=str).encode("utf-8"))
except Exception:
# Fallback to sys.getsizeof if JSON serialization fails
try:
return sys.getsizeof(value)
except Exception:
# Conservative fallback estimate
return 1024
def _evict_if_needed(self, required_size: int) -> None:
"""Evict oldest entries if needed to make room for new entry.
Uses a min-heap to efficiently find and evict entries with earliest expiry times.
Also cleans up stale heap entries for keys that no longer exist in cache.
Args:
required_size: Size in bytes needed for new entry.
"""
if self._current_size_bytes + required_size <= self._max_size_bytes:
return
while self._expiry_heap and self._current_size_bytes + required_size > self._max_size_bytes:
expiry_time, key = heapq.heappop(self._expiry_heap)
if key in self._cache:
_, cached_expiry, size = self._cache[key]
if cached_expiry == expiry_time:
del self._cache[key]
self._current_size_bytes -= size
# else: stale heap entry, already updated/removed, skip it
async def get(self, key: str) -> Any | None:
"""Get a value from the cache.
Args:
key: The cache key.
Returns:
The cached value or None if not found or expired.
"""
if key not in self._cache:
return None
value, expiry, size = self._cache[key]
if time.time() > expiry:
del self._cache[key]
self._current_size_bytes -= size
return None
return value
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
"""Set a value in the cache.
Args:
key: The cache key.
value: The value to cache.
ttl_seconds: Time to live in seconds. If None, uses default TTL.
"""
ttl = ttl_seconds if ttl_seconds is not None else self._default_ttl
expiry = time.time() + ttl
size = self._estimate_size(value)
# Remove old entry if exists
if key in self._cache:
old_size = self._cache[key][2]
self._current_size_bytes -= old_size
# Evict if needed
self._evict_if_needed(size)
self._cache[key] = (value, expiry, size)
self._current_size_bytes += size
heapq.heappush(self._expiry_heap, (expiry, key))
async def remove(self, key: str) -> None:
"""Remove a value from the cache.
Args:
key: The cache key.
"""
entry = self._cache.pop(key, None)
if entry is not None:
self._current_size_bytes -= entry[2]
self._cache.pop(key, None)
def create_protection_scopes_cache_key(request: ProtectionScopesRequest) -> str:
"""Create a cache key for a ProtectionScopesRequest.
The key is based on the serialized request content (excluding correlation_id).
Args:
request: The protection scopes request.
Returns:
A string cache key.
"""
data = request.to_dict(exclude_none=True)
for field in ["correlation_id"]:
data.pop(field, None)
json_str = json.dumps(data, sort_keys=True)
return f"purview:protection_scopes:{hashlib.sha256(json_str.encode()).hexdigest()}"
__all__ = [
"CacheProvider",
]
@@ -5,15 +5,19 @@ import base64
import inspect
import json
from typing import Any, cast
from uuid import uuid4
import httpx
from agent_framework import AGENT_FRAMEWORK_USER_AGENT
from agent_framework._logging import get_logger
from agent_framework.observability import get_tracer
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from opentelemetry import trace
from ._exceptions import (
PurviewAuthenticationError,
PurviewPaymentRequiredError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
@@ -28,6 +32,8 @@ from ._models import (
)
from ._settings import PurviewSettings
logger = get_logger("agent_framework.purview")
class PurviewClient:
"""Async client for calling Graph Purview endpoints.
@@ -85,13 +91,39 @@ class PurviewClient:
with get_tracer().start_as_current_span("purview.process_content"):
token = await self._get_token(tenant_id=request.tenant_id)
url = f"{self._graph_uri}/users/{request.user_id}/dataSecurityAndGovernance/processContent"
return cast(ProcessContentResponse, await self._post(url, request, ProcessContentResponse, token))
headers = {}
# Add If-None-Match header if scope_identifier is present
if hasattr(request, "scope_identifier") and request.scope_identifier:
headers["If-None-Match"] = request.scope_identifier
# Add Prefer: evaluateInline header if process_inline is True
if hasattr(request, "process_inline") and request.process_inline:
headers["Prefer"] = "evaluateInline"
response = await self._post(
url, request, ProcessContentResponse, token, headers=headers, return_response=True
)
if isinstance(response, tuple) and len(response) == 2:
response_obj, _ = response
return cast(ProcessContentResponse, response_obj)
return cast(ProcessContentResponse, response)
async def get_protection_scopes(self, request: ProtectionScopesRequest) -> ProtectionScopesResponse:
with get_tracer().start_as_current_span("purview.get_protection_scopes"):
token = await self._get_token()
url = f"{self._graph_uri}/users/{request.user_id}/dataSecurityAndGovernance/protectionScopes/compute"
return cast(ProtectionScopesResponse, await self._post(url, request, ProtectionScopesResponse, token))
response = await self._post(url, request, ProtectionScopesResponse, token, return_response=True)
# Extract etag from response headers
if isinstance(response, tuple) and len(response) == 2:
response_obj, headers = response
if "etag" in headers:
etag_value = headers["etag"].strip('"')
response_obj.scope_identifier = etag_value
return cast(ProtectionScopesResponse, response_obj)
return cast(ProtectionScopesResponse, response)
async def send_content_activities(self, request: ContentActivitiesRequest) -> ContentActivitiesResponse:
with get_tracer().start_as_current_span("purview.send_content_activities"):
@@ -99,16 +131,44 @@ class PurviewClient:
url = f"{self._graph_uri}/users/{request.user_id}/dataSecurityAndGovernance/activities/contentActivities"
return cast(ContentActivitiesResponse, await self._post(url, request, ContentActivitiesResponse, token))
async def _post(self, url: str, model: Any, response_type: type[Any], token: str) -> Any:
async def _post(
self,
url: str,
model: Any,
response_type: type[Any],
token: str,
headers: dict[str, str] | None = None,
return_response: bool = False,
) -> Any:
if hasattr(model, "correlation_id") and not model.correlation_id:
model.correlation_id = str(uuid4())
correlation_id = getattr(model, "correlation_id", None)
if correlation_id:
span = trace.get_current_span()
if span and span.is_recording():
span.set_attribute("correlation_id", correlation_id)
logger.info(f"Purview request to {url} with correlation_id: {correlation_id}")
payload = model.model_dump(by_alias=True, exclude_none=True, mode="json")
headers = {
request_headers = {
"Authorization": f"Bearer {token}",
"User-Agent": AGENT_FRAMEWORK_USER_AGENT,
"Content-Type": "application/json",
}
resp = await self._client.post(url, json=payload, headers=headers)
if correlation_id:
request_headers["client-request-id"] = correlation_id
if headers:
request_headers.update(headers)
resp = await self._client.post(url, json=payload, headers=request_headers)
if resp.status_code in (401, 403):
raise PurviewAuthenticationError(f"Auth failure {resp.status_code}: {resp.text}")
if resp.status_code == 402:
if self._settings.ignore_payment_required:
return response_type() # type: ignore[call-arg, no-any-return]
raise PurviewPaymentRequiredError(f"Payment required {resp.status_code}: {resp.text}")
if resp.status_code == 429:
raise PurviewRateLimitError(f"Rate limited {resp.status_code}: {resp.text}")
if resp.status_code not in (200, 201, 202):
@@ -117,10 +177,21 @@ class PurviewClient:
data = resp.json()
except ValueError:
data = {}
try:
# Prefer pydantic-style model_validate if present, else fall back to constructor.
if hasattr(response_type, "model_validate"):
return response_type.model_validate(data) # type: ignore[no-any-return]
return response_type(**data) # type: ignore[call-arg, no-any-return]
except Exception as ex: # pragma: no cover
response_obj = response_type.model_validate(data) # type: ignore[no-any-return]
else:
response_obj = response_type(**data) # type: ignore[call-arg, no-any-return]
# Extract correlation_id from response headers if response object supports it
if "client-request-id" in resp.headers and hasattr(response_obj, "correlation_id"):
response_obj.correlation_id = resp.headers["client-request-id"]
logger.info(f"Purview response from {url} with correlation_id: {response_obj.correlation_id}")
if return_response:
return (response_obj, resp.headers)
return response_obj
except Exception as ex:
raise PurviewServiceError(f"Failed to deserialize Purview response: {ex}") from ex
@@ -7,6 +7,7 @@ from agent_framework.exceptions import ServiceResponseException
__all__ = [
"PurviewAuthenticationError",
"PurviewPaymentRequiredError",
"PurviewRateLimitError",
"PurviewRequestError",
"PurviewServiceError",
@@ -21,6 +22,10 @@ class PurviewAuthenticationError(PurviewServiceError):
"""Authentication / authorization failure (401/403)."""
class PurviewPaymentRequiredError(PurviewServiceError):
"""Payment required (402)."""
class PurviewRateLimitError(PurviewServiceError):
"""Rate limiting or throttling (429)."""
@@ -8,7 +8,9 @@ from agent_framework._logging import get_logger
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from ._cache import CacheProvider
from ._client import PurviewClient
from ._exceptions import PurviewPaymentRequiredError
from ._models import Activity
from ._processor import ScopedContentProcessor
from ._settings import PurviewSettings
@@ -38,9 +40,10 @@ class PurviewPolicyMiddleware(AgentMiddleware):
self,
credential: TokenCredential | AsyncTokenCredential,
settings: PurviewSettings,
cache_provider: CacheProvider | None = None,
) -> None:
self._client = PurviewClient(credential, settings)
self._processor = ScopedContentProcessor(self._client, settings)
self._processor = ScopedContentProcessor(self._client, settings, cache_provider)
self._settings = settings
async def process(
@@ -62,9 +65,14 @@ class PurviewPolicyMiddleware(AgentMiddleware):
)
context.terminate = True
return
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy pre-check: {ex}")
if not self._settings.ignore_payment_required:
raise
except Exception as ex:
# Log and continue if there's an error in the pre-check
logger.error(f"Error in Purview policy pre-check: {ex}")
if not self._settings.ignore_exceptions:
raise
await next(context)
@@ -86,9 +94,14 @@ class PurviewPolicyMiddleware(AgentMiddleware):
else:
# Streaming responses are not supported for post-checks
logger.debug("Streaming responses are not supported for Purview policy post-checks")
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy post-check: {ex}")
if not self._settings.ignore_payment_required:
raise
except Exception as ex:
# Log and continue if there's an error in the post-check
logger.error(f"Error in Purview policy post-check: {ex}")
if not self._settings.ignore_exceptions:
raise
class PurviewChatPolicyMiddleware(ChatMiddleware):
@@ -118,9 +131,10 @@ class PurviewChatPolicyMiddleware(ChatMiddleware):
self,
credential: TokenCredential | AsyncTokenCredential,
settings: PurviewSettings,
cache_provider: CacheProvider | None = None,
) -> None:
self._client = PurviewClient(credential, settings)
self._processor = ScopedContentProcessor(self._client, settings)
self._processor = ScopedContentProcessor(self._client, settings, cache_provider)
self._settings = settings
async def process(
@@ -134,15 +148,20 @@ class PurviewChatPolicyMiddleware(ChatMiddleware):
context.messages, Activity.UPLOAD_TEXT
)
if should_block_prompt:
from agent_framework import ChatMessage
from agent_framework import ChatMessage, ChatResponse
context.result = [ # type: ignore[assignment]
ChatMessage(role="system", text=self._settings.blocked_prompt_message)
]
blocked_message = ChatMessage(role="system", text=self._settings.blocked_prompt_message)
context.result = ChatResponse(messages=[blocked_message])
context.terminate = True
return
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy pre-check: {ex}")
if not self._settings.ignore_payment_required:
raise
except Exception as ex:
logger.error(f"Error in Purview policy pre-check: {ex}")
if not self._settings.ignore_exceptions:
raise
await next(context)
@@ -157,12 +176,17 @@ class PurviewChatPolicyMiddleware(ChatMiddleware):
messages, Activity.UPLOAD_TEXT, user_id=resolved_user_id
)
if should_block_response:
from agent_framework import ChatMessage
from agent_framework import ChatMessage, ChatResponse
context.result = [ # type: ignore[assignment]
ChatMessage(role="system", text=self._settings.blocked_response_message)
]
blocked_message = ChatMessage(role="system", text=self._settings.blocked_response_message)
context.result = ChatResponse(messages=[blocked_message])
else:
logger.debug("Streaming responses are not supported for Purview policy post-checks")
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy post-check: {ex}")
if not self._settings.ignore_payment_required:
raise
except Exception as ex:
logger.error(f"Error in Purview policy post-check: {ex}")
if not self._settings.ignore_exceptions:
raise
@@ -642,7 +642,9 @@ class ContentToProcess(_AliasSerializable):
class ProcessContentRequest(_AliasSerializable):
_ALIASES: ClassVar[dict[str, str]] = {"content_to_process": "contentToProcess"}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"user_id", "tenant_id", "correlation_id", "process_inline"}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {
"correlation_id",
}
def __init__(
self,
@@ -651,6 +653,7 @@ class ProcessContentRequest(_AliasSerializable):
tenant_id: str,
correlation_id: str | None = None,
process_inline: bool | None = None,
scope_identifier: str | None = None,
**kwargs: Any,
) -> None:
# Extract aliased values from kwargs
@@ -668,10 +671,11 @@ class ProcessContentRequest(_AliasSerializable):
self.tenant_id = tenant_id
self.correlation_id = correlation_id
self.process_inline = process_inline
self.scope_identifier = scope_identifier
class ProtectionScopesRequest(_AliasSerializable):
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"user_id", "tenant_id", "correlation_id", "scope_identifier"}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"correlation_id"}
_ALIASES: ClassVar[dict[str, str]] = {
"pivot_on": "pivotOn",
"device_metadata": "deviceMetadata",
@@ -743,7 +747,7 @@ class ContentActivitiesRequest(_AliasSerializable):
"scope_identifier": "scopeIdentifier",
"content_to_process": "contentMetadata",
}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"tenant_id", "correlation_id"}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"correlation_id"}
def __init__(
self,
@@ -800,12 +804,15 @@ class ProcessContentResponse(_AliasSerializable):
"protection_scope_state": "protectionScopeState",
"policy_actions": "policyActions",
"processing_errors": "processingErrors",
"correlation_id": "correlationId",
}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"correlation_id"}
id: str | None
protection_scope_state: ProtectionScopeState | None
policy_actions: list[DlpActionInfo] | None
processing_errors: list[ProcessingError] | None
correlation_id: str | None
def __init__(
self,
@@ -813,6 +820,7 @@ class ProcessContentResponse(_AliasSerializable):
protection_scope_state: ProtectionScopeState | None = None,
policy_actions: list[DlpActionInfo | MutableMapping[str, Any]] | None = None,
processing_errors: list[ProcessingError | MutableMapping[str, Any]] | None = None,
correlation_id: str | None = None,
**kwargs: Any,
) -> None:
# Extract aliased values from kwargs
@@ -822,6 +830,8 @@ class ProcessContentResponse(_AliasSerializable):
policy_actions = kwargs["policyActions"]
if "processingErrors" in kwargs:
processing_errors = kwargs["processingErrors"]
if "correlationId" in kwargs:
correlation_id = kwargs["correlationId"]
# Convert to objects
converted_policy_actions: list[DlpActionInfo] | None = None
@@ -838,12 +848,12 @@ class ProcessContentResponse(_AliasSerializable):
[pe if isinstance(pe, ProcessingError) else ProcessingError(**pe) for pe in processing_errors],
)
# Call parent without explicit params with aliases
super().__init__(**kwargs)
self.id = id
self.protection_scope_state = protection_scope_state
self.policy_actions = converted_policy_actions
self.processing_errors = converted_processing_errors
self.correlation_id = correlation_id
class PolicyScope(_AliasSerializable):
@@ -909,15 +919,22 @@ class PolicyScope(_AliasSerializable):
class ProtectionScopesResponse(_AliasSerializable):
_ALIASES: ClassVar[dict[str, str]] = {"scope_identifier": "scopeIdentifier", "scopes": "value"}
_ALIASES: ClassVar[dict[str, str]] = {
"scope_identifier": "scopeIdentifier",
"scopes": "value",
"correlation_id": "correlationId",
}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"correlation_id"}
scope_identifier: str | None
scopes: list[PolicyScope] | None
correlation_id: str | None
def __init__(
self,
scope_identifier: str | None = None,
scopes: list[PolicyScope | MutableMapping[str, Any]] | None = None,
correlation_id: str | None = None,
**kwargs: Any,
) -> None:
# Extract aliased values from kwargs before they're normalized by parent
@@ -925,6 +942,8 @@ class ProtectionScopesResponse(_AliasSerializable):
scope_identifier = kwargs["scopeIdentifier"]
if "value" in kwargs:
scopes = kwargs["value"]
if "correlationId" in kwargs:
correlation_id = kwargs["correlationId"]
converted_scopes: list[PolicyScope] | None = None
if scopes is not None:
@@ -936,22 +955,32 @@ class ProtectionScopesResponse(_AliasSerializable):
super().__init__(**kwargs)
self.scope_identifier = scope_identifier
self.scopes = converted_scopes
self.correlation_id = correlation_id
class ContentActivitiesResponse(_AliasSerializable):
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"status_code"}
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"correlation_id"}
_ALIASES: ClassVar[dict[str, str]] = {"correlation_id": "correlationId"}
status_code: int | None
error: ErrorDetails | None
correlation_id: str | None
def __init__(
self,
status_code: int | None = None,
error: ErrorDetails | MutableMapping[str, Any] | None = None,
correlation_id: str | None = None,
**kwargs: Any,
) -> None:
if "correlationId" in kwargs:
correlation_id = kwargs["correlationId"]
if isinstance(error, MutableMapping):
error = ErrorDetails(**error)
super().__init__(status_code=status_code, error=error, **kwargs)
super().__init__(status_code=status_code, error=error, correlation_id=correlation_id, **kwargs)
self.status_code = status_code
self.error = error # type: ignore[assignment]
self.correlation_id = correlation_id
__all__ = [
@@ -1,13 +1,17 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import uuid
from collections.abc import Iterable, MutableMapping
from typing import Any
from agent_framework import ChatMessage
from agent_framework._logging import get_logger
from ._cache import CacheProvider, InMemoryCacheProvider, create_protection_scopes_cache_key
from ._client import PurviewClient
from ._exceptions import PurviewPaymentRequiredError
from ._models import (
Activity,
ActivityMetadata,
@@ -16,22 +20,25 @@ from ._models import (
DeviceMetadata,
DlpAction,
DlpActionInfo,
ExecutionMode,
IntegratedAppMetadata,
OperatingSystemSpecifications,
PolicyLocation,
ProcessContentRequest,
ProcessContentResponse,
ProcessConversationMetadata,
ProcessingError,
ProtectedAppMetadata,
ProtectionScopesRequest,
ProtectionScopesResponse,
ProtectionScopeState,
PurviewTextContent,
RestrictionAction,
translate_activity,
)
from ._settings import PurviewSettings
logger = get_logger("agent_framework.purview")
def _is_valid_guid(value: str | None) -> bool:
"""Check if a string is a valid GUID/UUID format using uuid module."""
@@ -47,9 +54,13 @@ def _is_valid_guid(value: str | None) -> bool:
class ScopedContentProcessor:
"""Combine protection scopes, process content, and content activities logic."""
def __init__(self, client: PurviewClient, settings: PurviewSettings):
def __init__(self, client: PurviewClient, settings: PurviewSettings, cache_provider: CacheProvider | None = None):
self._client = client
self._settings = settings
self._cache: CacheProvider = cache_provider or InMemoryCacheProvider(
default_ttl_seconds=settings.cache_ttl_seconds, max_size_bytes=settings.max_cache_size_bytes
)
self._background_tasks: set[asyncio.Task[Any]] = set()
async def process_messages(
self, messages: Iterable[ChatMessage], activity: Activity, user_id: str | None = None
@@ -173,7 +184,7 @@ class ScopedContentProcessor:
user_id=resolved_user_id, # Use the resolved user_id for all messages
tenant_id=tenant_id,
correlation_id=meta.correlation_id,
process_inline=True if self._settings.process_inline else None,
process_inline=None, # Will be set based on execution mode
)
results.append(req)
return results, resolved_user_id
@@ -191,23 +202,86 @@ class ScopedContentProcessor:
integrated_app_metadata=pc_request.content_to_process.integrated_app_metadata,
correlation_id=pc_request.correlation_id,
)
ps_resp = await self._client.get_protection_scopes(ps_req)
should_process, dlp_actions = self._check_applicable_scopes(pc_request, ps_resp)
# Check for tenant-level 402 exception cache first
tenant_payment_cache_key = f"purview:payment_required:{pc_request.tenant_id}"
cached_payment_exception = await self._cache.get(tenant_payment_cache_key)
if isinstance(cached_payment_exception, PurviewPaymentRequiredError):
raise cached_payment_exception
cache_key = create_protection_scopes_cache_key(ps_req)
cached_ps_resp = await self._cache.get(cache_key)
if cached_ps_resp is not None:
if isinstance(cached_ps_resp, ProtectionScopesResponse):
ps_resp = cached_ps_resp
else:
try:
ps_resp = await self._client.get_protection_scopes(ps_req)
await self._cache.set(cache_key, ps_resp, ttl_seconds=self._settings.cache_ttl_seconds)
except PurviewPaymentRequiredError as ex:
# Cache the exception at tenant level so all subsequent requests for this tenant fail fast
await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=self._settings.cache_ttl_seconds)
raise
if ps_resp.scope_identifier:
pc_request.scope_identifier = ps_resp.scope_identifier
should_process, dlp_actions, execution_mode = self._check_applicable_scopes(pc_request, ps_resp)
if should_process:
# Set process_inline based on execution mode
pc_request.process_inline = execution_mode == ExecutionMode.EVALUATE_INLINE
# If execution mode is offline, queue the PC request in background
if execution_mode != ExecutionMode.EVALUATE_INLINE:
task = asyncio.create_task(self._process_content_background(pc_request, cache_key))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
pc_resp = await self._client.process_content(pc_request)
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
await self._cache.remove(cache_key)
pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions)
return pc_resp
# No applicable scopes - send content activities in background
ca_req = ContentActivitiesRequest(
user_id=pc_request.user_id,
tenant_id=pc_request.tenant_id,
content_to_process=pc_request.content_to_process,
correlation_id=pc_request.correlation_id,
)
ca_resp = await self._client.send_content_activities(ca_req)
if ca_resp.error:
return ProcessContentResponse(processing_errors=[ProcessingError(message=str(ca_resp.error))])
return ProcessContentResponse()
task = asyncio.create_task(self._send_content_activities_background(ca_req))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
# Respond with HttpStatusCode 204(No Content)
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
async def _process_content_background(self, pc_request: ProcessContentRequest, cache_key: str) -> None:
"""Process content in background for offline execution mode."""
try:
pc_resp = await self._client.process_content(pc_request)
# If protection scope state is modified, make another PC request and invalidate cache
if pc_request.scope_identifier and pc_resp.protection_scope_state == ProtectionScopeState.MODIFIED:
await self._cache.remove(cache_key)
await self._client.process_content(pc_request)
except Exception as ex:
# Log errors but don't propagate since this is fire-and-forget
logger.warning(f"Background process content request failed: {ex}")
async def _send_content_activities_background(self, ca_req: ContentActivitiesRequest) -> None:
"""Send content activities in background without blocking."""
try:
await self._client.send_content_activities(ca_req)
except Exception as ex:
# Log errors but don't propagate since this is fire-and-forget
logger.warning(f"Background content activities request failed: {ex}")
@staticmethod
def _combine_policy_actions(
@@ -225,11 +299,22 @@ class ScopedContentProcessor:
@staticmethod
def _check_applicable_scopes(
pc_request: ProcessContentRequest, ps_response: ProtectionScopesResponse
) -> tuple[bool, list[DlpActionInfo]]:
) -> tuple[bool, list[DlpActionInfo], ExecutionMode]:
"""Check if any scopes are applicable to the request.
Args:
pc_request: The process content request
ps_response: The protection scopes response
Returns:
A tuple of (should_process, dlp_actions, execution_mode)
"""
req_activity = translate_activity(pc_request.content_to_process.activity_metadata.activity)
location = pc_request.content_to_process.protected_app_metadata.application_location
should_process: bool = False
dlp_actions: list[DlpActionInfo] = []
execution_mode: ExecutionMode = ExecutionMode.EVALUATE_OFFLINE # Default to offline
for scope in ps_response.scopes or []:
# Check if all activities in req_activity are present in scope.activities using bitwise flags.
activity_match = bool(scope.activities and (scope.activities & req_activity) == req_activity)
@@ -246,6 +331,11 @@ class ScopedContentProcessor:
break
if activity_match and location_match:
should_process = True
# If any scope has EvaluateInline, upgrade to inline mode
if scope.execution_mode == ExecutionMode.EVALUATE_INLINE:
execution_mode = ExecutionMode.EVALUATE_INLINE
if scope.policy_actions:
dlp_actions.extend(scope.policy_actions)
return should_process, dlp_actions
return should_process, dlp_actions, execution_mode
@@ -41,18 +41,23 @@ class PurviewSettings(AFBaseSettings):
Attributes:
app_name: Public app name.
app_version: Optional version string of the application.
tenant_id: Optional tenant id (guid) of the user making the request.
purview_app_location: Optional app location for policy evaluation.
graph_base_uri: Base URI for Microsoft Graph.
blocked_prompt_message: Custom message to return when a prompt is blocked by policy.
blocked_response_message: Custom message to return when a response is blocked by policy.
ignore_exceptions: If True, all Purview exceptions will be logged but not thrown in middleware.
ignore_payment_required: If True, 402 payment required errors will be logged but not thrown.
cache_ttl_seconds: Time to live for cache entries in seconds (default 14400 = 4 hours).
max_cache_size_bytes: Maximum cache size in bytes (default 200MB).
"""
app_name: str = Field(...)
app_version: str | None = Field(default=None)
tenant_id: str | None = Field(default=None)
purview_app_location: PurviewAppLocation | None = Field(default=None)
graph_base_uri: str = Field(default="https://graph.microsoft.com/v1.0/")
process_inline: bool = Field(default=False, description="Process content inline if supported.")
blocked_prompt_message: str = Field(
default="Prompt blocked by policy",
description="Message to return when a prompt is blocked by policy.",
@@ -61,6 +66,22 @@ class PurviewSettings(AFBaseSettings):
default="Response blocked by policy",
description="Message to return when a response is blocked by policy.",
)
ignore_exceptions: bool = Field(
default=False,
description="If True, all Purview exceptions will be logged but not thrown in middleware.",
)
ignore_payment_required: bool = Field(
default=False,
description="If True, 402 payment required errors will be logged but not thrown.",
)
cache_ttl_seconds: int = Field(
default=14400,
description="Time to live for cache entries in seconds (default 14400 = 4 hours).",
)
max_cache_size_bytes: int = Field(
default=200 * 1024 * 1024,
description="Maximum cache size in bytes (default 200MB).",
)
model_config = SettingsConfigDict(populate_by_name=True, validate_assignment=True)
+196
View File
@@ -0,0 +1,196 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Purview cache provider."""
import asyncio
from agent_framework_purview._cache import (
InMemoryCacheProvider,
create_protection_scopes_cache_key,
)
from agent_framework_purview._models import PolicyLocation, ProtectionScopesRequest
class TestInMemoryCacheProvider:
"""Test InMemoryCacheProvider functionality."""
async def test_cache_set_and_get(self) -> None:
"""Test basic set and get operations."""
cache = InMemoryCacheProvider()
await cache.set("key1", "value1")
result = await cache.get("key1")
assert result == "value1"
async def test_cache_get_nonexistent_key(self) -> None:
"""Test get returns None for non-existent key."""
cache = InMemoryCacheProvider()
result = await cache.get("nonexistent")
assert result is None
async def test_cache_expiration(self) -> None:
"""Test that cached values expire after TTL."""
cache = InMemoryCacheProvider(default_ttl_seconds=1)
await cache.set("key1", "value1")
result = await cache.get("key1")
assert result == "value1"
await asyncio.sleep(1.1)
result = await cache.get("key1")
assert result is None
async def test_cache_custom_ttl(self) -> None:
"""Test that custom TTL overrides default."""
cache = InMemoryCacheProvider(default_ttl_seconds=10)
await cache.set("key1", "value1", ttl_seconds=1)
result = await cache.get("key1")
assert result == "value1"
await asyncio.sleep(1.1)
result = await cache.get("key1")
assert result is None
async def test_cache_update_existing_key(self) -> None:
"""Test updating an existing cache entry."""
cache = InMemoryCacheProvider()
await cache.set("key1", "value1")
await cache.set("key1", "value2")
result = await cache.get("key1")
assert result == "value2"
async def test_cache_remove(self) -> None:
"""Test removing a cache entry."""
cache = InMemoryCacheProvider()
await cache.set("key1", "value1")
await cache.remove("key1")
result = await cache.get("key1")
assert result is None
async def test_cache_remove_nonexistent_key(self) -> None:
"""Test removing non-existent key does not raise error."""
cache = InMemoryCacheProvider()
await cache.remove("nonexistent")
async def test_cache_size_limit_eviction(self) -> None:
"""Test that cache evicts old entries when size limit is reached."""
cache = InMemoryCacheProvider(max_size_bytes=200)
await cache.set("key1", "a" * 50)
await cache.set("key2", "b" * 50)
await cache.set("key3", "c" * 50)
await cache.set("key4", "d" * 100)
result1 = await cache.get("key1")
assert result1 is None
async def test_estimate_size_with_pydantic_model(self) -> None:
"""Test size estimation with Pydantic models."""
cache = InMemoryCacheProvider()
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
await cache.set("key1", request)
result = await cache.get("key1")
assert result == request
async def test_estimate_size_fallback(self) -> None:
"""Test size estimation fallback for non-serializable objects."""
cache = InMemoryCacheProvider()
class CustomObject:
pass
obj = CustomObject()
await cache.set("key1", obj)
result = await cache.get("key1")
assert result == obj
async def test_cache_multiple_updates(self) -> None:
"""Test that updating a key multiple times maintains correct size tracking."""
cache = InMemoryCacheProvider(max_size_bytes=1000)
await cache.set("key1", "a" * 100)
initial_size = cache._current_size_bytes
await cache.set("key1", "b" * 200)
assert cache._current_size_bytes != initial_size
async def test_eviction_with_stale_heap_entries(self) -> None:
"""Test that eviction correctly handles stale heap entries."""
cache = InMemoryCacheProvider(max_size_bytes=500)
await cache.set("key1", "a" * 100, ttl_seconds=10)
await cache.set("key2", "b" * 100, ttl_seconds=10)
await cache.set("key1", "c" * 100, ttl_seconds=20)
await cache.set("key3", "d" * 300)
result = await cache.get("key1")
assert result is not None
class TestCreateProtectionScopesCacheKey:
"""Test cache key generation for ProtectionScopesRequest."""
def test_cache_key_deterministic(self) -> None:
"""Test that same request generates same cache key."""
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request1 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
request2 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
key1 = create_protection_scopes_cache_key(request1)
key2 = create_protection_scopes_cache_key(request2)
assert key1 == key2
def test_cache_key_different_for_different_requests(self) -> None:
"""Test that different requests generate different cache keys."""
location1 = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id1"})
location2 = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id2"})
request1 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location1])
request2 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location2])
key1 = create_protection_scopes_cache_key(request1)
key2 = create_protection_scopes_cache_key(request2)
assert key1 != key2
def test_cache_key_excludes_correlation_id(self) -> None:
"""Test that correlation_id is excluded from cache key."""
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request1 = ProtectionScopesRequest(
user_id="user1", tenant_id="tenant1", locations=[location], correlation_id="corr1"
)
request2 = ProtectionScopesRequest(
user_id="user1", tenant_id="tenant1", locations=[location], correlation_id="corr2"
)
key1 = create_protection_scopes_cache_key(request1)
key2 = create_protection_scopes_cache_key(request2)
assert key1 == key2
def test_cache_key_format(self) -> None:
"""Test that cache key has expected format."""
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
key = create_protection_scopes_cache_key(request)
assert key.startswith("purview:protection_scopes:")
assert len(key) > len("purview:protection_scopes:")
@@ -74,7 +74,8 @@ class TestPurviewChatPolicyMiddleware:
await middleware.process(chat_context, mock_next)
assert chat_context.terminate
assert chat_context.result
msg = chat_context.result[0] # type: ignore[index]
assert hasattr(chat_context.result, "messages")
msg = chat_context.result.messages[0]
assert msg.role in ("system", Role.SYSTEM)
assert "blocked" in msg.text.lower()
@@ -112,7 +113,7 @@ class TestPurviewChatPolicyMiddleware:
chat_options=chat_options,
is_streaming=True,
)
with patch.object(middleware._processor, "process_messages", return_value=False) as mock_proc:
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc:
async def mock_next(ctx: ChatContext) -> None:
ctx.result = MagicMock()
@@ -123,7 +124,10 @@ class TestPurviewChatPolicyMiddleware:
async def test_chat_middleware_handles_post_check_exception(
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
) -> None:
"""Test that exceptions in post-check are logged but don't affect result."""
"""Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True."""
# Set ignore_exceptions to True to test exception suppression
middleware._settings.ignore_exceptions = True
call_count = 0
async def mock_process_messages(*args, **kwargs):
@@ -136,7 +140,6 @@ class TestPurviewChatPolicyMiddleware:
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
# Create a mock result with messages attribute
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
ctx.result = result
@@ -147,3 +150,127 @@ class TestPurviewChatPolicyMiddleware:
assert call_count == 2
# Result should still be set
assert chat_context.result is not None
async def test_chat_middleware_uses_consistent_user_id(
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
) -> None:
"""Test that the same user_id from pre-check is used in post-check."""
captured_user_ids = []
async def mock_process_messages(messages, activity, user_id=None):
captured_user_ids.append(user_id)
return (False, "resolved-user-123")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
ctx.result = result
await middleware.process(chat_context, mock_next)
# Should have been called twice
assert len(captured_user_ids) == 2
# First call should have None (no user_id provided yet)
assert captured_user_ids[0] is None
# Second call should have the resolved user_id from first call
assert captured_user_ids[1] == "resolved-user-123"
async def test_chat_middleware_handles_payment_required_pre_check(self, mock_credential: AsyncMock) -> None:
"""Test that 402 in pre-check is handled based on settings."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
# Test with ignore_payment_required=False
settings = PurviewSettings(app_name="Test App", ignore_payment_required=False)
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
)
async def mock_process_messages(*args, **kwargs):
raise PurviewPaymentRequiredError("Payment required")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
raise AssertionError("next should not be called")
# Should raise the exception
with pytest.raises(PurviewPaymentRequiredError):
await middleware.process(context, mock_next)
async def test_chat_middleware_ignores_payment_required_when_configured(self, mock_credential: AsyncMock) -> None:
"""Test that 402 is ignored when ignore_payment_required=True."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
settings = PurviewSettings(app_name="Test App", ignore_payment_required=True)
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
)
async def mock_process_messages(*args, **kwargs):
raise PurviewPaymentRequiredError("Payment required")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
context.result = result
# Should not raise, just log
await middleware.process(context, mock_next)
# Next should have been called
assert context.result is not None
async def test_chat_middleware_handles_result_without_messages_attribute(
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
) -> None:
"""Test middleware handles result that doesn't have messages attribute."""
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
async def mock_next(ctx: ChatContext) -> None:
# Set result to something without messages attribute
ctx.result = "Some string result"
await middleware.process(chat_context, mock_next)
# Should not crash, result should be unchanged
assert chat_context.result == "Some string result"
async def test_chat_middleware_with_ignore_exceptions(self, mock_credential: AsyncMock) -> None:
"""Test that middleware respects ignore_exceptions setting."""
settings = PurviewSettings(app_name="Test App", ignore_exceptions=True)
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
)
async def mock_process_messages(*args, **kwargs):
raise ValueError("Some error")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
context.result = result
# Should not raise, just log
await middleware.process(context, mock_next)
# Next should have been called
assert context.result is not None
@@ -12,6 +12,7 @@ from agent_framework_purview import PurviewSettings
from agent_framework_purview._client import PurviewClient
from agent_framework_purview._exceptions import (
PurviewAuthenticationError,
PurviewPaymentRequiredError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
@@ -157,6 +158,7 @@ class TestPurviewClient:
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {"id": "response-123", "protectionScopeState": "notModified"}
with patch.object(client._client, "post", return_value=mock_response):
@@ -174,6 +176,7 @@ class TestPurviewClient:
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {} # Add headers attribute
mock_response.json.return_value = {"scopeIdentifier": "scope-123", "value": []}
with patch.object(client._client, "post", return_value=mock_response):
@@ -236,3 +239,157 @@ class TestPurviewClient:
pytest.raises(PurviewRequestError, match="Purview request failed"),
):
await client.process_content(request)
async def test_prefer_header_sent_when_process_inline_true(
self, client: PurviewClient, content_to_process_factory
) -> None:
"""Test that Prefer: evaluateInline header is sent when process_inline is True."""
content = content_to_process_factory()
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
process_inline=True,
)
posted_headers = {}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {}
async def capture_post(url, json, headers):
posted_headers.update(headers)
return mock_response
with patch.object(client._client, "post", side_effect=capture_post):
await client.process_content(request)
assert "Prefer" in posted_headers
assert posted_headers["Prefer"] == "evaluateInline"
async def test_prefer_header_not_sent_when_process_inline_false(
self, client: PurviewClient, content_to_process_factory
) -> None:
"""Test that Prefer header is not sent when process_inline is False."""
content = content_to_process_factory()
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
process_inline=False,
)
posted_headers = {}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {}
async def capture_post(url, json, headers):
posted_headers.update(headers)
return mock_response
with patch.object(client._client, "post", side_effect=capture_post):
await client.process_content(request)
assert "Prefer" not in posted_headers
async def test_prefer_header_not_sent_when_process_inline_none(
self, client: PurviewClient, content_to_process_factory
) -> None:
"""Test that Prefer header is not sent when process_inline is None."""
content = content_to_process_factory()
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
process_inline=None,
)
posted_headers = {}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {}
async def capture_post(url, json, headers):
posted_headers.update(headers)
return mock_response
with patch.object(client._client, "post", side_effect=capture_post):
await client.process_content(request)
assert "Prefer" not in posted_headers
async def test_scope_identifier_extraction_from_etag(self, client: PurviewClient) -> None:
"""Test that scope_identifier is extracted from ETag header."""
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {"etag": '"test-scope-id"'}
mock_response.json.return_value = {"value": []}
with patch.object(client._client, "post", return_value=mock_response):
req = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1")
response = await client.get_protection_scopes(req)
assert response.scope_identifier == "test-scope-id"
async def test_scope_identifier_sent_as_if_none_match_header(
self, client: PurviewClient, content_to_process_factory
) -> None:
"""Test that scope_identifier is sent as If-None-Match header."""
content = content_to_process_factory()
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
scope_identifier="test-scope-id",
)
posted_headers = {}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {}
async def capture_post(url, json, headers):
posted_headers.update(headers)
return mock_response
with patch.object(client._client, "post", side_effect=capture_post):
await client.process_content(request)
assert "If-None-Match" in posted_headers
assert posted_headers["If-None-Match"] == "test-scope-id"
async def test_402_payment_required_raises_exception_by_default(self, client: PurviewClient) -> None:
"""Test that 402 raises exception when ignore_payment_required is False."""
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 402
mock_response.text = "Payment required"
with patch.object(client._client, "post", return_value=mock_response):
req = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1")
with pytest.raises(PurviewPaymentRequiredError):
await client.get_protection_scopes(req)
async def test_402_payment_required_returns_empty_when_ignored(self, mock_credential: MagicMock) -> None:
"""Test that 402 returns empty response when ignore_payment_required is True."""
settings = PurviewSettings(app_name="Test App", ignore_payment_required=True)
client = PurviewClient(mock_credential, settings)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 402
mock_response.text = "Payment required"
with patch.object(client._client, "post", return_value=mock_response):
req = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1")
response = await client.get_protection_scopes(req)
# Should return empty response without raising
assert response is not None
assert response.scopes is None or response.scopes == []
await client.close()
@@ -4,6 +4,7 @@
from agent_framework_purview import (
PurviewAuthenticationError,
PurviewPaymentRequiredError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
@@ -25,6 +26,12 @@ class TestPurviewExceptions:
assert str(error) == "Authentication failed"
assert isinstance(error, PurviewServiceError)
def test_purview_payment_required_error(self) -> None:
"""Test PurviewPaymentRequiredError exception."""
error = PurviewPaymentRequiredError("Payment required")
assert str(error) == "Payment required"
assert isinstance(error, PurviewServiceError)
def test_purview_rate_limit_error(self) -> None:
"""Test PurviewRateLimitError exception."""
error = PurviewRateLimitError("Rate limit exceeded")
@@ -120,6 +120,9 @@ class TestPurviewPolicyMiddleware:
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test middleware handles result that doesn't have messages attribute."""
# Set ignore_exceptions to True so AttributeError is caught and logged
middleware._settings.ignore_exceptions = True
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello")])
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
@@ -153,7 +156,10 @@ class TestPurviewPolicyMiddleware:
async def test_middleware_handles_pre_check_exception(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that exceptions in pre-check are logged but don't stop processing."""
"""Test that exceptions in pre-check are logged but don't stop processing when ignore_exceptions=True."""
# Set ignore_exceptions to True
middleware._settings.ignore_exceptions = True
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
with patch.object(
@@ -175,7 +181,10 @@ class TestPurviewPolicyMiddleware:
async def test_middleware_handles_post_check_exception(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that exceptions in post-check are logged but don't affect result."""
"""Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True."""
# Set ignore_exceptions to True
middleware._settings.ignore_exceptions = True
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
call_count = 0
@@ -199,3 +208,49 @@ class TestPurviewPolicyMiddleware:
# Result should still be set
assert context.result is not None
assert hasattr(context.result, "messages")
async def test_middleware_with_ignore_exceptions_true(self, mock_credential: AsyncMock) -> None:
"""Test that middleware logs but doesn't throw when ignore_exceptions is True."""
settings = PurviewSettings(app_name="Test App", ignore_exceptions=True)
middleware = PurviewPolicyMiddleware(mock_credential, settings)
mock_agent = MagicMock()
mock_agent.name = "test-agent"
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
# Mock processor to raise an exception
async def mock_process_messages(*args, **kwargs):
raise ValueError("Test error")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx):
ctx.result = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Response")])
# Should not raise, just log
await middleware.process(context, mock_next)
# Result should be set because next was called despite the error
assert context.result is not None
async def test_middleware_with_ignore_exceptions_false(self, mock_credential: AsyncMock) -> None:
"""Test that middleware throws exceptions when ignore_exceptions is False."""
settings = PurviewSettings(app_name="Test App", ignore_exceptions=False)
middleware = PurviewPolicyMiddleware(mock_credential, settings)
mock_agent = MagicMock()
mock_agent.name = "test-agent"
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
# Mock processor to raise an exception
async def mock_process_messages(*args, **kwargs):
raise ValueError("Test error")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx):
pass
# Should raise the exception
with pytest.raises(ValueError, match="Test error"):
await middleware.process(context, mock_next)
+2 -5
View File
@@ -237,10 +237,7 @@ class TestModelDeserialization:
dumped = request.model_dump(by_alias=True, exclude_none=True, mode="json")
# Check that excluded fields are not present
assert "user_id" not in dumped
assert "tenant_id" not in dumped
assert "user_id" in dumped
assert "tenant_id" in dumped
assert "correlation_id" not in dumped
# Check that content is present
assert "contentToProcess" in dumped
+271 -14
View File
@@ -170,7 +170,7 @@ class TestScopedContentProcessor:
request = process_content_request_factory()
response = ProtectionScopesResponse(**{"value": None})
should_process, actions = processor._check_applicable_scopes(request, response)
should_process, actions, execution_mode = processor._check_applicable_scopes(request, response)
assert should_process is False
assert actions == []
@@ -200,7 +200,7 @@ class TestScopedContentProcessor:
})
response = ProtectionScopesResponse(**{"value": [scope]})
should_process, actions = processor._check_applicable_scopes(request, response)
should_process, actions, execution_mode = processor._check_applicable_scopes(request, response)
assert should_process is True
assert len(actions) == 1
@@ -220,7 +220,7 @@ class TestScopedContentProcessor:
async def test_process_with_scopes_calls_client_methods(
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test _process_with_scopes calls get_protection_scopes and process_content."""
"""Test _process_with_scopes calls get_protection_scopes when scopes response is empty."""
from agent_framework_purview._models import (
ContentActivitiesResponse,
ProtectionScopesResponse,
@@ -237,9 +237,10 @@ class TestScopedContentProcessor:
response = await processor._process_with_scopes(request)
mock_client.get_protection_scopes.assert_called_once()
# When no scopes apply, process_content is not called (activities are sent in background)
mock_client.process_content.assert_not_called()
mock_client.send_content_activities.assert_called_once()
assert response.id is None
# The response should have id=204 (No Content) when no scopes apply
assert response.id == "204"
async def test_map_messages_with_user_id_in_additional_properties(self, mock_client: AsyncMock) -> None:
"""Test user_id extraction from message additional_properties."""
@@ -308,7 +309,7 @@ class TestScopedContentProcessor:
async def test_process_content_sends_activities_when_not_applicable(
self, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test that content activities are sent when scopes don't apply."""
"""Test that response is returned when scopes don't apply (activities sent in background)."""
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
@@ -325,7 +326,7 @@ class TestScopedContentProcessor:
mock_ps_response.scopes = []
mock_client.get_protection_scopes.return_value = mock_ps_response
# Mock send_content_activities to return success
# Mock send_content_activities to return success (called in background)
mock_ca_response = MagicMock()
mock_ca_response.error = None
mock_client.send_content_activities.return_value = mock_ca_response
@@ -334,14 +335,13 @@ class TestScopedContentProcessor:
mock_client.get_protection_scopes.assert_called_once()
mock_client.process_content.assert_not_called()
mock_client.send_content_activities.assert_called_once()
# When content activities succeed, response has no errors (processing_errors can be None or empty)
assert response.processing_errors is None or response.processing_errors == []
# Response should have id=204 when no scopes apply
assert response.id == "204"
async def test_process_content_handles_activities_error(
self, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test error handling when content activities fail."""
"""Test that errors in background activities don't affect the response."""
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
@@ -358,12 +358,269 @@ class TestScopedContentProcessor:
mock_ps_response.scopes = []
mock_client.get_protection_scopes.return_value = mock_ps_response
# Mock send_content_activities to return error
# Mock send_content_activities to return error (called in background task)
mock_ca_response = MagicMock()
mock_ca_response.error = "Test error message"
mock_client.send_content_activities.return_value = mock_ca_response
response = await processor._process_with_scopes(pc_request)
assert len(response.processing_errors) == 1
assert response.processing_errors[0].message == "Test error message"
# Since activities are sent in background, errors don't affect the response
# Response should have id=204 when no scopes apply
assert response.id == "204"
class TestUserIdResolution:
"""Test user ID resolution from various sources."""
@pytest.fixture
def mock_client(self) -> AsyncMock:
"""Create a mock Purview client."""
client = AsyncMock()
client.get_user_info_from_token = AsyncMock(
return_value={
"tenant_id": "12345678-1234-1234-1234-123456789012",
"user_id": "11111111-1111-1111-1111-111111111111",
"client_id": "12345678-1234-1234-1234-123456789012",
}
)
return client
@pytest.fixture
def settings(self) -> PurviewSettings:
"""Create settings."""
return PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
purview_app_location=PurviewAppLocation(
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
),
)
async def test_user_id_from_token_when_no_other_source(self, mock_client: AsyncMock) -> None:
"""Test user_id is extracted from token when no other source available."""
settings = PurviewSettings(app_name="Test App") # No tenant_id or app_location
processor = ScopedContentProcessor(mock_client, settings)
messages = [ChatMessage(role=Role.USER, text="Test")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
mock_client.get_user_info_from_token.assert_called_once()
assert user_id == "11111111-1111-1111-1111-111111111111"
async def test_user_id_from_additional_properties_takes_priority(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test user_id from additional_properties takes priority over token."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(
role=Role.USER,
text="Test",
additional_properties={"user_id": "22222222-2222-2222-2222-222222222222"},
)
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
# Token info should not be called since we have user_id in message
mock_client.get_user_info_from_token.assert_not_called()
assert user_id == "22222222-2222-2222-2222-222222222222"
async def test_user_id_from_author_name_as_fallback(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test user_id is extracted from author_name when it's a valid GUID."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(
role=Role.USER,
text="Test",
author_name="33333333-3333-3333-3333-333333333333",
)
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
assert user_id == "33333333-3333-3333-3333-333333333333"
async def test_author_name_ignored_if_not_valid_guid(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test author_name is ignored if it's not a valid GUID."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(
role=Role.USER,
text="Test",
author_name="John Doe", # Not a GUID
)
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
# Should return empty since author_name is not a valid GUID
assert user_id is None
assert len(requests) == 0
async def test_provided_user_id_used_as_last_resort(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test provided_user_id parameter is used as last resort."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [ChatMessage(role=Role.USER, text="Test")]
requests, user_id = await processor._map_messages(
messages, Activity.UPLOAD_TEXT, provided_user_id="44444444-4444-4444-4444-444444444444"
)
assert user_id == "44444444-4444-4444-4444-444444444444"
async def test_invalid_provided_user_id_ignored(self, mock_client: AsyncMock, settings: PurviewSettings) -> None:
"""Test invalid provided_user_id is ignored."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [ChatMessage(role=Role.USER, text="Test")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT, provided_user_id="not-a-guid")
assert user_id is None
assert len(requests) == 0
async def test_multiple_messages_same_user_id(self, mock_client: AsyncMock, settings: PurviewSettings) -> None:
"""Test that all messages use the same resolved user_id."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(
role=Role.USER, text="First", additional_properties={"user_id": "55555555-5555-5555-5555-555555555555"}
),
ChatMessage(role=Role.ASSISTANT, text="Response"),
ChatMessage(role=Role.USER, text="Second"),
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
assert user_id == "55555555-5555-5555-5555-555555555555"
# All requests should have the same user_id
assert all(req.user_id == "55555555-5555-5555-5555-555555555555" for req in requests)
async def test_first_valid_user_id_in_messages_is_used(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test that the first valid user_id found in messages is used for all."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(role=Role.USER, text="First", author_name="Not a GUID"),
ChatMessage(
role=Role.ASSISTANT,
text="Response",
additional_properties={"user_id": "66666666-6666-6666-6666-666666666666"},
),
ChatMessage(
role=Role.USER, text="Third", additional_properties={"user_id": "77777777-7777-7777-7777-777777777777"}
),
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
# First valid user_id (from second message) should be used
assert user_id == "66666666-6666-6666-6666-666666666666"
assert all(req.user_id == "66666666-6666-6666-6666-666666666666" for req in requests)
class TestScopedContentProcessorCaching:
"""Test caching functionality in ScopedContentProcessor."""
@pytest.fixture
def mock_client(self) -> AsyncMock:
"""Create a mock Purview client."""
client = AsyncMock()
client.get_user_info_from_token = AsyncMock(
return_value={
"tenant_id": "12345678-1234-1234-1234-123456789012",
"user_id": "12345678-1234-1234-1234-123456789012",
"client_id": "12345678-1234-1234-1234-123456789012",
}
)
client.get_protection_scopes = AsyncMock()
return client
@pytest.fixture
def settings(self) -> PurviewSettings:
"""Create test settings."""
location = PurviewAppLocation(location_type=PurviewLocationType.APPLICATION, location_value="app-id")
return PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
default_user_id="12345678-1234-1234-1234-123456789012",
purview_app_location=location,
)
async def test_protection_scopes_cached_on_first_call(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test that protection scopes response is cached after first call."""
from agent_framework_purview._cache import InMemoryCacheProvider
from agent_framework_purview._models import ProtectionScopesResponse
cache_provider = InMemoryCacheProvider()
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider)
mock_client.get_protection_scopes.return_value = ProtectionScopesResponse(
scope_identifier="scope-123", scopes=[]
)
messages = [ChatMessage(role=Role.USER, text="Test")]
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
mock_client.get_protection_scopes.assert_called_once()
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
mock_client.get_protection_scopes.assert_called_once()
async def test_payment_required_exception_cached_at_tenant_level(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test that 402 payment required exceptions are cached at tenant level."""
from agent_framework_purview._cache import InMemoryCacheProvider
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
cache_provider = InMemoryCacheProvider()
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider)
mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required")
messages = [ChatMessage(role=Role.USER, text="Test")]
with pytest.raises(PurviewPaymentRequiredError):
await processor.process_messages(
messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012"
)
mock_client.get_protection_scopes.assert_called_once()
with pytest.raises(PurviewPaymentRequiredError):
await processor.process_messages(
messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012"
)
mock_client.get_protection_scopes.assert_called_once()
async def test_custom_cache_provider_used(self, mock_client: AsyncMock, settings: PurviewSettings) -> None:
"""Test that custom cache provider is used when provided."""
from agent_framework_purview._cache import InMemoryCacheProvider
custom_cache = InMemoryCacheProvider(default_ttl_seconds=60)
processor = ScopedContentProcessor(mock_client, settings, cache_provider=custom_cache)
assert processor._cache is custom_cache
assert processor._cache._default_ttl == 60
@@ -18,7 +18,6 @@ class TestPurviewSettings:
assert settings.graph_base_uri == "https://graph.microsoft.com/v1.0/"
assert settings.tenant_id is None
assert settings.purview_app_location is None
assert settings.process_inline is False
def test_settings_with_custom_values(self) -> None:
"""Test PurviewSettings with custom values."""
@@ -28,13 +27,11 @@ class TestPurviewSettings:
app_name="Test App",
graph_base_uri="https://graph.microsoft-ppe.com",
tenant_id="test-tenant-id",
process_inline=True,
purview_app_location=app_location,
)
assert settings.graph_base_uri == "https://graph.microsoft-ppe.com"
assert settings.tenant_id == "test-tenant-id"
assert settings.process_inline is True
assert settings.purview_app_location.location_value == "app-123"
@pytest.mark.parametrize(
@@ -2,9 +2,14 @@
This getting-started sample shows how to attach Microsoft Purview policy evaluation to an Agent Framework `ChatAgent` using the **middleware** approach.
**What this sample demonstrates:**
1. Configure an Azure OpenAI chat client
2. Add Purview policy enforcement middleware (`PurviewPolicyMiddleware`)
3. Run a short conversation and observe prompt / response blocking behavior
3. Add Purview policy enforcement at the chat client level (`PurviewChatPolicyMiddleware`)
4. Implement a custom cache provider for advanced caching scenarios
5. Run conversations and observe prompt / response blocking behavior
**Note:** Caching is **automatic** and enabled by default with sensible defaults (30-minute TTL, 200MB max size).
---
## 1. Setup
@@ -20,8 +25,6 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat
| `PURVIEW_CERT_PATH` | Yes (when cert auth on) | Path to your .pfx certificate |
| `PURVIEW_CERT_PASSWORD` | Optional | Password for encrypted certs |
*A demo default exists in code for illustration only—always set your own value.
### 2. Auth Modes Supported
#### A. Interactive Browser Authentication (default)
@@ -42,7 +45,7 @@ $env:PURVIEW_CERT_PATH = "C:\path\to\cert.pfx"
$env:PURVIEW_CERT_PASSWORD = "optional-password"
```
Certificate steps (summary): create / register app, generate certificate, upload public key, export .pfx with private key, grant required Graph / Purview permissions.
Certificate steps (summary): create / register entra app, generate certificate, upload public key, export .pfx with private key, grant required Graph / Purview permissions.
---
@@ -61,18 +64,39 @@ If interactive auth is used, a browser window will appear the first time.
## 4. How It Works
The sample demonstrates three different scenarios:
### A. Agent Middleware (`run_with_agent_middleware`)
1. Builds an Azure OpenAI chat client (using the environment endpoint / deployment)
2. Chooses credential mode (certificate vs interactive)
3. Creates `PurviewPolicyMiddleware` with `PurviewSettings`
4. Injects middleware into the agent at construction
5. Sends two user messages sequentially
6. Prints results (or policy block messages)
7. Uses default caching automatically
### B. Chat Client Middleware (`run_with_chat_middleware`)
1. Creates a chat client with `PurviewChatPolicyMiddleware` attached directly
2. Policy evaluation happens at the chat client level rather than agent level
3. Demonstrates an alternative integration point for Purview policies
4. Uses default caching automatically
### C. Custom Cache Provider (`run_with_custom_cache_provider`)
1. Implements the `CacheProvider` protocol with a custom class (`SimpleDictCacheProvider`)
2. Shows how to add custom logging and metrics to cache operations
3. The custom provider must implement three async methods:
- `async def get(self, key: str) -> Any | None`
- `async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None`
- `async def remove(self, key: str) -> None`
**Policy Behavior:**
Prompt blocks set a system-level message: `Prompt blocked by policy` and terminate the run early. Response blocks rewrite the output to `Response blocked by policy`.
---
## 5. Code Snippet (Middleware Injection)
## 5. Code Snippets
### Agent Middleware Injection
```python
agent = ChatAgent(
@@ -80,9 +104,41 @@ agent = ChatAgent(
instructions="You are good at telling jokes.",
name="Joker",
middleware=[
PurviewPolicyMiddleware(credential, PurviewSettings(app_name="Sample App", default_user_id="<guid>"))
PurviewPolicyMiddleware(credential, PurviewSettings(app_name="Sample App"))
],
)
```
### Custom Cache Provider Implementation
This is only needed if you want to integrate with external caching systems.
```python
class SimpleDictCacheProvider:
"""Custom cache provider that implements the CacheProvider protocol."""
def __init__(self) -> None:
self._cache: dict[str, Any] = {}
async def get(self, key: str) -> Any | None:
"""Get a value from the cache."""
return self._cache.get(key)
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
"""Set a value in the cache."""
self._cache[key] = value
async def remove(self, key: str) -> None:
"""Remove a value from the cache."""
self._cache.pop(key, None)
# Use the custom cache provider
custom_cache = SimpleDictCacheProvider()
middleware = PurviewPolicyMiddleware(
credential,
PurviewSettings(app_name="Sample App"),
cache_provider=custom_cache,
)
```
---
@@ -5,7 +5,10 @@ Shows:
1. Creating a basic chat agent
2. Adding Purview policy evaluation via AGENT middleware (agent-level)
3. Adding Purview policy evaluation via CHAT middleware (chat-client level)
4. Running a threaded conversation and printing results
4. Implementing a custom cache provider for advanced caching scenarios
5. Running threaded conversations and printing results
Note: Caching is automatic and enabled by default.
Environment variables:
- AZURE_OPENAI_ENDPOINT (required)
@@ -31,7 +34,6 @@ from azure.identity import (
InteractiveBrowserCredential,
)
# Purview integration pieces
from agent_framework.microsoft import (
PurviewPolicyMiddleware,
PurviewChatPolicyMiddleware,
@@ -42,6 +44,59 @@ JOKER_NAME = "Joker"
JOKER_INSTRUCTIONS = "You are good at telling jokes. Keep responses concise."
# Custom Cache Provider Implementation
class SimpleDictCacheProvider:
"""A simple custom cache provider that stores everything in a dictionary.
This example demonstrates how to implement the CacheProvider protocol.
"""
def __init__(self) -> None:
"""Initialize the simple dictionary cache."""
self._cache: dict[str, Any] = {}
self._access_count: dict[str, int] = {}
async def get(self, key: str) -> Any | None:
"""Get a value from the cache.
Args:
key: The cache key.
Returns:
The cached value or None if not found.
"""
value = self._cache.get(key)
if value is not None:
self._access_count[key] = self._access_count.get(key, 0) + 1
print(f"[CustomCache] Cache HIT for key: {key[:50]}... (accessed {self._access_count[key]} times)")
else:
print(f"[CustomCache] Cache MISS for key: {key[:50]}...")
return value
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None:
"""Set a value in the cache.
Args:
key: The cache key.
value: The value to cache.
ttl_seconds: Time to live in seconds (ignored in this simple implementation).
"""
self._cache[key] = value
print(f"[CustomCache] Cached value for key: {key[:50]}... (TTL: {ttl_seconds}s)")
async def remove(self, key: str) -> None:
"""Remove a value from the cache.
Args:
key: The cache key.
"""
if key in self._cache:
del self._cache[key]
self._access_count.pop(key, None)
print(f"[CustomCache] Removed key: {key[:50]}...")
def _get_env(name: str, *, required: bool = True, default: str | None = None) -> str:
val = os.environ.get(name, default)
if required and not val:
@@ -161,9 +216,91 @@ async def run_with_chat_middleware() -> None:
)
print("Second response (chat middleware):\n", second)
async def run_with_custom_cache_provider() -> None:
"""Demonstrate implementing and using a custom cache provider."""
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
if not endpoint:
print("Skipping custom cache provider run: AZURE_OPENAI_ENDPOINT not set")
return
deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini")
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
chat_client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential())
custom_cache = SimpleDictCacheProvider()
purview_agent_middleware = PurviewPolicyMiddleware(
build_credential(),
PurviewSettings(
app_name="Agent Framework Sample App (Custom Provider)",
),
cache_provider=custom_cache,
)
agent = ChatAgent(
chat_client=chat_client,
instructions=JOKER_INSTRUCTIONS,
name=JOKER_NAME,
middleware=purview_agent_middleware,
)
print("-- Custom Cache Provider Path --")
print("Using SimpleDictCacheProvider")
first: AgentRunResponse = await agent.run(
ChatMessage(role=Role.USER, text="Tell me a joke about a programmer.", additional_properties={"user_id": user_id})
)
print("First response (custom provider):\n", first)
second: AgentRunResponse = await agent.run(
ChatMessage(role=Role.USER, text="That's hilarious! One more?", additional_properties={"user_id": user_id})
)
print("Second response (custom provider):\n", second)
"""Demonstrate using the default built-in cache."""
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
if not endpoint:
print("Skipping default cache run: AZURE_OPENAI_ENDPOINT not set")
return
deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o-mini")
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
chat_client = AzureOpenAIChatClient(deployment_name=deployment, endpoint=endpoint, credential=AzureCliCredential())
# No cache_provider specified - uses default InMemoryCacheProvider
purview_agent_middleware = PurviewPolicyMiddleware(
build_credential(),
PurviewSettings(
app_name="Agent Framework Sample App (Default Cache)",
cache_ttl_seconds=3600,
max_cache_size_bytes=100 * 1024 * 1024, # 100MB
),
)
agent = ChatAgent(
chat_client=chat_client,
instructions=JOKER_INSTRUCTIONS,
name=JOKER_NAME,
middleware=purview_agent_middleware,
)
print("-- Default Cache Path --")
print("Using default InMemoryCacheProvider with settings-based configuration")
first: AgentRunResponse = await agent.run(
ChatMessage(role=Role.USER, text="Tell me a joke about AI.", additional_properties={"user_id": user_id})
)
print("First response (default cache):\n", first)
second: AgentRunResponse = await agent.run(
ChatMessage(role=Role.USER, text="Nice! Another AI joke please.", additional_properties={"user_id": user_id})
)
print("Second response (default cache):\n", second)
async def main() -> None:
print("== Purview Agent Sample (Agent & Chat Middleware) ==")
print("== Purview Agent Sample (Middleware with Automatic Caching) ==")
try:
await run_with_agent_middleware()
except Exception as ex: # pragma: no cover - demo resilience
@@ -174,6 +311,11 @@ async def main() -> None:
except Exception as ex: # pragma: no cover - demo resilience
print(f"Chat middleware path failed: {ex}")
try:
await run_with_custom_cache_provider()
except Exception as ex: # pragma: no cover - demo resilience
print(f"Custom cache provider path failed: {ex}")
if __name__ == "__main__":
asyncio.run(main())
@@ -7,5 +7,14 @@ This folder contains examples demonstrating different ways to manage conversatio
| File | Description |
|------|-------------|
| [`custom_chat_message_store_thread.py`](custom_chat_message_store_thread.py) | Demonstrates how to implement a custom `ChatMessageStore` for persisting conversation history. Shows how to create a custom store with serialization/deserialization capabilities and integrate it with agents for thread management across multiple sessions. |
| [`suspend_resume_thread.py`](suspend_resume_thread.py) | Shows how to suspend and resume conversation threads, allowing you to save the state of a conversation and continue it later. This is useful for long-running conversations or when you need to persist conversation state across application restarts. |
| [`redis_chat_message_store_thread.py`](redis_chat_message_store_thread.py) | Comprehensive examples of using the Redis-backed `RedisChatMessageStore` for persistent conversation storage. Covers basic usage, user session management, conversation persistence across app restarts, thread serialization, and automatic message trimming. Requires Redis server and demonstrates production-ready patterns for scalable chat applications. |
| [`suspend_resume_thread.py`](suspend_resume_thread.py) | Shows how to suspend and resume conversation threads, comparing service-managed threads (Azure AI) with in-memory threads (OpenAI). Demonstrates saving conversation state and continuing it later, useful for long-running conversations or persisting state across application restarts. |
## Environment Variables
Make sure to set the following environment variables before running the examples:
- `OPENAI_API_KEY`: Your OpenAI API key (required for all samples)
- `OPENAI_CHAT_MODEL_ID`: The OpenAI model to use (e.g., `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`) (required for all samples)
- `AZURE_AI_PROJECT_ENDPOINT`: Azure AI Project endpoint URL (required for service-managed thread examples)
- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment (required for service-managed thread examples)
@@ -8,6 +8,14 @@ from agent_framework import ChatMessage, ChatMessageStoreProtocol
from agent_framework._threads import ChatMessageStoreState
from agent_framework.openai import OpenAIChatClient
"""
Custom Chat Message Store Thread Example
This sample demonstrates how to implement and use a custom chat message store
for thread management, allowing you to persist conversation history in your
preferred storage solution (database, file system, etc.).
"""
class CustomChatMessageStore(ChatMessageStoreProtocol):
"""Implementation of custom chat message store.
@@ -24,13 +32,22 @@ class CustomChatMessageStore(ChatMessageStoreProtocol):
async def list_messages(self) -> list[ChatMessage]:
return self._messages
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
@classmethod
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "CustomChatMessageStore":
"""Create a new instance from serialized state."""
store = cls()
await store.update_from_state(serialized_store_state, **kwargs)
return store
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
"""Update this instance from serialized state."""
if serialized_store_state:
state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs)
if state.messages:
self._messages.extend(state.messages)
async def serialize_state(self, **kwargs: Any) -> Any:
async def serialize(self, **kwargs: Any) -> Any:
"""Serialize this store's state."""
state = ChatMessageStoreState(messages=self._messages)
return state.to_dict(**kwargs)
@@ -42,8 +59,8 @@ async def main() -> None:
# OpenAI Chat Client is used as an example here,
# other chat clients can be used as well.
agent = OpenAIChatClient().create_agent(
name="Joker",
instructions="You are good at telling jokes.",
name="CustomBot",
instructions="You are a helpful assistant that remembers our conversation.",
# Use custom chat message store.
# If not provided, the default in-memory store will be used.
chat_message_store_factory=CustomChatMessageStore,
@@ -53,7 +70,7 @@ async def main() -> None:
thread = agent.get_new_thread()
# Respond to user input.
query = "Tell me a joke about a pirate."
query = "Hello! My name is Alice and I love pizza."
print(f"User: {query}")
print(f"Agent: {await agent.run(query, thread=thread)}\n")
@@ -67,7 +84,7 @@ async def main() -> None:
resumed_thread = await agent.deserialize_thread(serialized_thread)
# Respond to user input.
query = "Now tell the same joke in the voice of a pirate, and add some emojis to the joke."
query = "What do you remember about me?"
print(f"User: {query}")
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
@@ -8,6 +8,14 @@ from agent_framework import AgentThread
from agent_framework.openai import OpenAIChatClient
from agent_framework.redis import RedisChatMessageStore
"""
Redis Chat Message Store Thread Example
This sample demonstrates how to use Redis as a chat message store for thread
management, enabling persistent conversation history storage across sessions
with Redis as the backend data store.
"""
async def example_manual_memory_store() -> None:
"""Basic example of using Redis chat message store."""
@@ -2,38 +2,51 @@
import asyncio
from agent_framework.azure import AzureAIAgentClient
from agent_framework.openai import OpenAIChatClient
from azure.identity.aio import AzureCliCredential
"""
Thread Suspend and Resume Example
This sample demonstrates how to suspend and resume conversation threads, comparing
service-managed threads (Azure AI) with in-memory threads (OpenAI) for persistent
conversation state across sessions.
"""
async def suspend_resume_service_managed_thread() -> None:
"""Demonstrates how to suspend and resume a service-managed thread."""
print("=== Suspend-Resume Service-Managed Thread ===")
# OpenAI Chat Client is used as an example here,
# other chat clients can be used as well.
agent = OpenAIChatClient().create_agent(name="Joker", instructions="You are good at telling jokes.")
# AzureAIAgentClient supports service-managed threads.
async with (
AzureCliCredential() as credential,
AzureAIAgentClient(async_credential=credential).create_agent(
name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation."
) as agent,
):
# Start a new thread for the agent conversation.
thread = agent.get_new_thread()
# Start a new thread for the agent conversation.
thread = agent.get_new_thread()
# Respond to user input.
query = "Hello! My name is Alice and I love pizza."
print(f"User: {query}")
print(f"Agent: {await agent.run(query, thread=thread)}\n")
# Respond to user input.
query = "Tell me a joke about a pirate."
print(f"User: {query}")
print(f"Agent: {await agent.run(query, thread=thread)}\n")
# Serialize the thread state, so it can be stored for later use.
serialized_thread = await thread.serialize()
# Serialize the thread state, so it can be stored for later use.
serialized_thread = await thread.serialize()
# The thread can now be saved to a database, file, or any other storage mechanism and loaded again later.
print(f"Serialized thread: {serialized_thread}\n")
# The thread can now be saved to a database, file, or any other storage mechanism and loaded again later.
print(f"Serialized thread: {serialized_thread}\n")
# Deserialize the thread state after loading from storage.
resumed_thread = await agent.deserialize_thread(serialized_thread)
# Deserialize the thread state after loading from storage.
resumed_thread = await agent.deserialize_thread(serialized_thread)
# Respond to user input.
query = "Now tell the same joke in the voice of a pirate, and add some emojis to the joke."
print(f"User: {query}")
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
# Respond to user input.
query = "What do you remember about me?"
print(f"User: {query}")
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
async def suspend_resume_in_memory_thread() -> None:
@@ -42,13 +55,15 @@ async def suspend_resume_in_memory_thread() -> None:
# OpenAI Chat Client is used as an example here,
# other chat clients can be used as well.
agent = OpenAIChatClient().create_agent(name="Joker", instructions="You are good at telling jokes.")
agent = OpenAIChatClient().create_agent(
name="MemoryBot", instructions="You are a helpful assistant that remembers our conversation."
)
# Start a new thread for the agent conversation.
thread = agent.get_new_thread()
# Respond to user input.
query = "Tell me a joke about a pirate."
query = "Hello! My name is Alice and I love pizza."
print(f"User: {query}")
print(f"Agent: {await agent.run(query, thread=thread)}\n")
@@ -62,7 +77,7 @@ async def suspend_resume_in_memory_thread() -> None:
resumed_thread = await agent.deserialize_thread(serialized_thread)
# Respond to user input.
query = "Now tell the same joke in the voice of a pirate, and add some emojis to the joke."
query = "What do you remember about me?"
print(f"User: {query}")
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
+1 -1
View File
@@ -193,7 +193,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ag-ui"
version = "1.0.0b251106"
version = "1.0.0b251106.post1"
source = { editable = "packages/ag-ui" }
dependencies = [
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },