mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-python-foundry-agents
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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'" },
|
||||
|
||||
Reference in New Issue
Block a user