mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
197788ef9e | ||
|
|
a2d9b6c77d | ||
|
|
0f328177b1 | ||
|
|
cdb2ba56cc | ||
|
|
9b568d24cb |
-28
@@ -71,34 +71,6 @@ curl -X POST http://localhost:8088/invocations \
|
||||
-d "Hello from Docker!"
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-invocations-echo-agent && cd hosted-invocations-echo-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-invocations-echo-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `Hosted-Invocations-EchoAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -107,29 +107,3 @@ azd env set SKILL_NAMES "support-style,escalation-policy"
|
||||
The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to download skills at startup.
|
||||
|
||||
> The `skills/` source folder is **not** deployed to Foundry — only the downloaded skills are used at runtime. The provisioning step must have been run against the same Foundry project before the agent can download the skills.
|
||||
|
||||
### Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-agent-skills && cd hosted-agent-skills
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-agent-skills
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
-28
@@ -174,34 +174,6 @@ The model receives the top three search results as additional context and cites
|
||||
|
||||
Replace the seed documents (or point the sample at an existing index with your own content) to ground the agent in your own knowledge base.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-azure-search-rag && cd hosted-azure-search-rag
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-azure-search-rag
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedAzureSearchRag.csproj` for the `PackageReference` alternative.
|
||||
|
||||
-26
@@ -104,32 +104,6 @@ curl -X POST http://localhost:8088/responses \
|
||||
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-chat-client-agent && cd hosted-chat-client-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-chat-client-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -112,34 +112,6 @@ docker run --rm -p 8088:8088 \
|
||||
|
||||
The bundled `resources/` folder is part of the published output and ships inside the image.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-files && cd hosted-files
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-files
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj).
|
||||
|
||||
@@ -107,32 +107,6 @@ curl -X POST http://localhost:8088/responses \
|
||||
-d '{"input": "Hello!", "model": "<your-agent-name>"}'
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-foundry-agent && cd hosted-foundry-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-foundry-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedFoundryAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -108,34 +108,6 @@ The agent has a single tool `GetAvailableHotels` defined as a C# method with `[D
|
||||
|
||||
The tool searches a mock database of 6 Seattle hotels and returns formatted results with name, location, rating, and pricing.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-local-tools && cd hosted-local-tools
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-local-tools
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedLocalTools.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -78,34 +78,6 @@ docker run --rm -p 8088:8088 \
|
||||
hosted-mcp-tools
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir mcp-tools && cd mcp-tools
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME mcp-tools
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -139,34 +139,6 @@ The script publishes the project, builds the image, runs the container with two
|
||||
`HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each
|
||||
user only sees their own memories, and exits non-zero on failure.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-memory-agent && cd hosted-memory-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-MemoryAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-memory-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the
|
||||
|
||||
-28
@@ -104,34 +104,6 @@ docker run --rm -p 8088:8088 \
|
||||
|
||||
Once deployed, telemetry flows to the Application Insights instance attached to your Foundry project. In the Foundry UI, the **Traces** tab next to **Playground** lists conversations and lets you drill into the span tree for any request.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-observability && cd hosted-observability
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-observability
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedObservability.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -111,34 +111,6 @@ The `TextSearchProvider` runs a mock search **before each model invocation**:
|
||||
|
||||
The model receives the search results as additional context and cites the source in its response. In production, replace `MockSearchAsync` with a call to Azure AI Search or your preferred search provider.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-text-rag && cd hosted-text-rag
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-text-rag
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedTextRag.csproj` for the `PackageReference` alternative.
|
||||
|
||||
-28
@@ -98,34 +98,6 @@ Using the Azure Developer CLI:
|
||||
azd ai agent invoke --local "What skills do you have available?"
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-toolbox-mcp-skills && cd hosted-toolbox-mcp-skills
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-toolbox-mcp-skills
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-5
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
|
||||
|
||||
-28
@@ -121,34 +121,6 @@ User message
|
||||
|
||||
The triage agent receives every message and hands off to the appropriate specialist. Specialists route back to the triage agent after responding, allowing for multi-turn conversations.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir triage-workflow && cd triage-workflow
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME triage-workflow
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative.
|
||||
|
||||
+2
-30
@@ -5,7 +5,7 @@ A hosted agent that demonstrates **multi-agent workflow orchestration**. Three t
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `hosted-workflow-simple`)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
@@ -22,7 +22,7 @@ Edit `.env` and set your Azure AI Foundry project endpoint:
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=hosted-workflow-simple
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
@@ -104,34 +104,6 @@ Input text
|
||||
|
||||
Each agent in the chain receives the output of the previous agent. The final result demonstrates how meaning is preserved (or subtly shifted) through multiple translation hops.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-workflows && cd hosted-workflows
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
```
|
||||
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
azd env set AGENT_NAME hosted-workflow-simple
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME hosted-workflow-simple
|
||||
```
|
||||
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
---
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowSimple.csproj` for the `PackageReference` alternative.
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview;
|
||||
@@ -20,7 +16,6 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
{
|
||||
private readonly IChannelHandler _channelHandler;
|
||||
private readonly IPurviewClient _purviewClient;
|
||||
private readonly ICacheProvider _cacheProvider;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -28,14 +23,12 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
/// </summary>
|
||||
/// <param name="channelHandler">The channel handler used to manage job channels.</param>
|
||||
/// <param name="purviewClient">The Purview client used to send requests to Purview.</param>
|
||||
/// <param name="cacheProvider">The cache provider used to store protection scopes results.</param>
|
||||
/// <param name="logger">The logger used to log information about background jobs.</param>
|
||||
/// <param name="purviewSettings">The settings used to configure Purview client behavior.</param>
|
||||
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ICacheProvider cacheProvider, ILogger logger, PurviewSettings purviewSettings)
|
||||
public BackgroundJobRunner(IChannelHandler channelHandler, IPurviewClient purviewClient, ILogger logger, PurviewSettings purviewSettings)
|
||||
{
|
||||
this._channelHandler = channelHandler;
|
||||
this._purviewClient = purviewClient;
|
||||
this._cacheProvider = cacheProvider;
|
||||
this._logger = logger;
|
||||
|
||||
for (int i = 0; i < purviewSettings.MaxConcurrentJobConsumers; i++)
|
||||
@@ -74,28 +67,6 @@ internal sealed class BackgroundJobRunner : IBackgroundJobRunner
|
||||
break;
|
||||
case ContentActivityJob contentActivityJob:
|
||||
_ = await this._purviewClient.SendContentActivitiesAsync(contentActivityJob.Request, CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case ScopeRetrievalJob scopeRetrievalJob:
|
||||
try
|
||||
{
|
||||
ProtectionScopesResponse response = await this._purviewClient.GetProtectionScopesAsync(scopeRetrievalJob.Request, CancellationToken.None).ConfigureAwait(false);
|
||||
await this._cacheProvider.SetAsync(scopeRetrievalJob.CacheKey, response, CancellationToken.None).ConfigureAwait(false);
|
||||
(bool shouldProcess, List<DlpActionInfo> _, ExecutionMode _) = ScopedContentProcessor.CheckApplicableScopes(scopeRetrievalJob.ProcessContentRequest, response);
|
||||
if (!shouldProcess)
|
||||
{
|
||||
ProcessContentRequest pcRequest = scopeRetrievalJob.ProcessContentRequest;
|
||||
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
|
||||
this._channelHandler.QueueJob(new ContentActivityJob(caRequest));
|
||||
}
|
||||
}
|
||||
catch (PurviewPaymentRequiredException ex)
|
||||
{
|
||||
await this._cacheProvider.SetAsync(
|
||||
new PaymentRequiredCacheKey(scopeRetrievalJob.Request.TenantId),
|
||||
new PaymentRequiredCacheEntry(ex.Message),
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Cached tenant-level payment required state.
|
||||
/// </summary>
|
||||
internal sealed class PaymentRequiredCacheEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="PaymentRequiredCacheEntry"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The payment required error message.</param>
|
||||
public PaymentRequiredCacheEntry(string? message)
|
||||
{
|
||||
this.Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The payment required error message.
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Common;
|
||||
|
||||
/// <summary>
|
||||
/// A cache key for tenant-level payment required state.
|
||||
/// </summary>
|
||||
internal sealed class PaymentRequiredCacheKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="PaymentRequiredCacheKey"/>.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The id of the tenant.</param>
|
||||
public PaymentRequiredCacheKey(string tenantId)
|
||||
{
|
||||
this.TenantId = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The id of the tenant.
|
||||
/// </summary>
|
||||
public string TenantId { get; set; }
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Class representing a job that refreshes the protection scopes cache in the background.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used by the parallel protection scopes retrieval path to warm the cache without blocking the
|
||||
/// foreground ProcessContent call.
|
||||
/// </remarks>
|
||||
internal sealed class ScopeRetrievalJob : BackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScopeRetrievalJob"/> class.
|
||||
/// </summary>
|
||||
/// <param name="request">The protection scopes request to send to Purview.</param>
|
||||
/// <param name="cacheKey">The cache key used to store the response.</param>
|
||||
/// <param name="processContentRequest">The original process content request that triggered scope retrieval.</param>
|
||||
public ScopeRetrievalJob(ProtectionScopesRequest request, ProtectionScopesCacheKey cacheKey, ProcessContentRequest processContentRequest)
|
||||
{
|
||||
this.Request = request;
|
||||
this.CacheKey = cacheKey;
|
||||
this.ProcessContentRequest = processContentRequest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the protection scopes request.
|
||||
/// </summary>
|
||||
public ProtectionScopesRequest Request { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache key used to store the response.
|
||||
/// </summary>
|
||||
public ProtectionScopesCacheKey CacheKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original process content request that triggered scope retrieval.
|
||||
/// </summary>
|
||||
public ProcessContentRequest ProcessContentRequest { get; }
|
||||
}
|
||||
@@ -53,10 +53,4 @@ internal sealed class ProcessContentRequest
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal string? ScopeIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the ProcessContent request should ask the service for inline evaluation.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
internal bool ProcessInline { get; set; }
|
||||
}
|
||||
|
||||
@@ -130,11 +130,6 @@ internal sealed class PurviewClient : IPurviewClient
|
||||
message.Headers.Add("If-None-Match", request.ScopeIdentifier);
|
||||
}
|
||||
|
||||
if (request.ProcessInline)
|
||||
{
|
||||
message.Headers.Add("Prefer", "evaluateInline");
|
||||
}
|
||||
|
||||
string content = JsonSerializer.Serialize(request, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentRequest)));
|
||||
message.Content = new StringContent(content, Encoding.UTF8, "application/json");
|
||||
|
||||
|
||||
@@ -218,8 +218,8 @@ The policy logic is identical; the only difference is the hook point in the pipe
|
||||
|
||||
The user id from the prompt message(s) is reused for the response evaluation so both evaluations map consistently to the same user.
|
||||
|
||||
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls. When a lookup is not cached, the middleware will refresh it in a background worker so the foreground ProcessContent request does not have to wait.
|
||||
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit. Payment Required responses from background scope lookups are cached at the tenant level so subsequent requests for the tenant short-circuit.
|
||||
There are several optimizations to speed up Purview calls. Protection scope lookups (the first step in evaluation) are cached to minimize network calls.
|
||||
If the policies allow content to be processed offline, the middleware will add the process content request to a channel and run it in a background worker. Similarly, the middleware will run a background request if no scopes apply and the interaction only has to be logged in Audit.
|
||||
|
||||
## Exceptions
|
||||
| Exception | Scenario |
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
@@ -194,60 +193,43 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
{
|
||||
ProtectionScopesRequest psRequest = CreateProtectionScopesRequest(pcRequest, pcRequest.UserId, pcRequest.TenantId, pcRequest.CorrelationId);
|
||||
|
||||
PaymentRequiredCacheEntry? cachedPaymentRequired = await this._cacheProvider.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
|
||||
new PaymentRequiredCacheKey(pcRequest.TenantId),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (cachedPaymentRequired != null)
|
||||
{
|
||||
throw new PurviewPaymentRequiredException(cachedPaymentRequired.Message ?? "Payment required");
|
||||
}
|
||||
|
||||
ProtectionScopesCacheKey cacheKey = new(psRequest);
|
||||
|
||||
ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ProtectionScopesResponse psResponse;
|
||||
|
||||
if (cacheResponse != null)
|
||||
{
|
||||
return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
psResponse = cacheResponse;
|
||||
}
|
||||
|
||||
try
|
||||
else
|
||||
{
|
||||
this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest));
|
||||
}
|
||||
catch (PurviewJobException)
|
||||
{
|
||||
// QueueJob already logs failures. Scope warmup is best effort; don't block ProcessContent.
|
||||
psResponse = await this._purviewClient.GetProtectionScopesAsync(psRequest, cancellationToken).ConfigureAwait(false);
|
||||
await this._cacheProvider.SetAsync(cacheKey, psResponse, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply locally-cached protection scopes to the request and dispatch ProcessContent appropriately.
|
||||
/// </summary>
|
||||
private async Task<ProcessContentResponse> ProcessWithCachedScopesAsync(
|
||||
ProcessContentRequest pcRequest,
|
||||
ProtectionScopesResponse psResponse,
|
||||
ProtectionScopesCacheKey cacheKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
pcRequest.ScopeIdentifier = psResponse.ScopeIdentifier;
|
||||
|
||||
(bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) = CheckApplicableScopes(pcRequest, psResponse);
|
||||
|
||||
if (shouldProcess)
|
||||
{
|
||||
pcRequest.ProcessInline = executionMode == ExecutionMode.EvaluateInline;
|
||||
|
||||
if (executionMode == ExecutionMode.EvaluateOffline)
|
||||
{
|
||||
this._channelHandler.QueueJob(new ProcessContentJob(pcRequest));
|
||||
return new ProcessContentResponse();
|
||||
}
|
||||
|
||||
return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions, cancellationToken).ConfigureAwait(false);
|
||||
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
|
||||
{
|
||||
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
|
||||
return pcResponse;
|
||||
}
|
||||
|
||||
ContentActivitiesRequest caRequest = new(pcRequest.UserId, pcRequest.TenantId, pcRequest.ContentToProcess, pcRequest.CorrelationId);
|
||||
@@ -256,30 +238,6 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
return new ProcessContentResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call ProcessContent and invalidate the protection scopes cache when the response indicates the cached scopes are stale.
|
||||
/// </summary>
|
||||
private async Task<ProcessContentResponse> CallProcessContentAsync(
|
||||
ProcessContentRequest pcRequest,
|
||||
ProtectionScopesCacheKey cacheKey,
|
||||
List<DlpActionInfo>? dlpActions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ProcessContentResponse pcResponse = await this._purviewClient.ProcessContentAsync(pcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (pcRequest.ScopeIdentifier != null && pcResponse.ProtectionScopeState == ProtectionScopeState.Modified)
|
||||
{
|
||||
await this._cacheProvider.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (dlpActions?.Count > 0)
|
||||
{
|
||||
pcResponse = CombinePolicyActions(pcResponse, dlpActions);
|
||||
}
|
||||
|
||||
return pcResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dedupe policy actions received from the service.
|
||||
/// </summary>
|
||||
@@ -290,21 +248,9 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
{
|
||||
if (actionInfos?.Count > 0)
|
||||
{
|
||||
List<DlpActionInfo> combinedActions = [];
|
||||
HashSet<(DlpAction Action, RestrictionAction? RestrictionAction)> seenActions = [];
|
||||
IEnumerable<DlpActionInfo> allActions = pcResponse.PolicyActions is null
|
||||
? actionInfos
|
||||
: pcResponse.PolicyActions.Concat(actionInfos);
|
||||
|
||||
foreach (DlpActionInfo actionInfo in allActions)
|
||||
{
|
||||
if (seenActions.Add((actionInfo.Action, actionInfo.RestrictionAction)))
|
||||
{
|
||||
combinedActions.Add(actionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
pcResponse.PolicyActions = combinedActions;
|
||||
pcResponse.PolicyActions = pcResponse.PolicyActions is null ?
|
||||
actionInfos :
|
||||
[.. pcResponse.PolicyActions, .. actionInfos];
|
||||
}
|
||||
|
||||
return pcResponse;
|
||||
@@ -316,7 +262,7 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
/// <param name="pcRequest">The process content request.</param>
|
||||
/// <param name="psResponse">The protection scopes response that was returned for the process content request.</param>
|
||||
/// <returns>A bool indicating if the content needs to be processed. A list of applicable actions from the scopes response, and the execution mode for the process content request.</returns>
|
||||
internal static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
private static (bool shouldProcess, List<DlpActionInfo> dlpActions, ExecutionMode executionMode) CheckApplicableScopes(ProcessContentRequest pcRequest, ProtectionScopesResponse psResponse)
|
||||
{
|
||||
ProtectionScopeActivities requestActivity = TranslateActivity(pcRequest.ContentToProcess.ActivityMetadata.Activity);
|
||||
|
||||
@@ -338,11 +284,7 @@ internal sealed class ScopedContentProcessor : IScopedContentProcessor
|
||||
|
||||
foreach (var location in scope.Locations ?? Array.Empty<PolicyLocation>())
|
||||
{
|
||||
if (location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
locationMatch = true;
|
||||
break;
|
||||
}
|
||||
locationMatch = location.DataType.EndsWith(locationType, StringComparison.OrdinalIgnoreCase) && location.Value.Equals(locationValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (activityMatch && locationMatch)
|
||||
|
||||
@@ -18,8 +18,6 @@ namespace Microsoft.Agents.AI.Purview.Serialization;
|
||||
[JsonSerializable(typeof(ContentActivitiesRequest))]
|
||||
[JsonSerializable(typeof(ContentActivitiesResponse))]
|
||||
[JsonSerializable(typeof(ProtectionScopesCacheKey))]
|
||||
[JsonSerializable(typeof(PaymentRequiredCacheKey))]
|
||||
[JsonSerializable(typeof(PaymentRequiredCacheEntry))]
|
||||
internal sealed partial class SourceGenerationContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -115,24 +115,6 @@ public sealed class PurviewClientTests : IDisposable
|
||||
Assert.Equal("\"test-scope-123\"", this._handler.IfNoneMatchHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithProcessInline_IncludesPreferHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidProcessContentRequest();
|
||||
request.ProcessInline = true;
|
||||
var expectedResponse = new ProcessContentResponse { Id = "test-id" };
|
||||
|
||||
this._handler.StatusCodeToReturn = HttpStatusCode.OK;
|
||||
this._handler.ResponseToReturn = JsonSerializer.Serialize(expectedResponse, PurviewSerializationUtils.SerializationSettings.GetTypeInfo(typeof(ProcessContentResponse)));
|
||||
|
||||
// Act
|
||||
await this._client.ProcessContentAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("evaluateInline", this._handler.PreferHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessContentAsync_WithRateLimitError_ThrowsPurviewRateLimitExceptionAsync()
|
||||
{
|
||||
@@ -548,7 +530,6 @@ public sealed class PurviewClientTests : IDisposable
|
||||
public HttpMethod? RequestMethod { get; private set; }
|
||||
public string? AuthorizationHeader { get; private set; }
|
||||
public string? IfNoneMatchHeader { get; private set; }
|
||||
public string? PreferHeader { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -566,11 +547,6 @@ public sealed class PurviewClientTests : IDisposable
|
||||
this.IfNoneMatchHeader = string.Join(", ", ifNoneMatchValues);
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValues("Prefer", out var preferValues))
|
||||
{
|
||||
this.PreferHeader = string.Join(", ", preferValues);
|
||||
}
|
||||
|
||||
// Throw HttpRequestException if configured
|
||||
if (this.ShouldThrowHttpRequestException)
|
||||
{
|
||||
|
||||
+40
-358
@@ -3,14 +3,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Purview.Models.Common;
|
||||
using Microsoft.Agents.AI.Purview.Models.Jobs;
|
||||
using Microsoft.Agents.AI.Purview.Models.Requests;
|
||||
using Microsoft.Agents.AI.Purview.Models.Responses;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Purview.UnitTests;
|
||||
@@ -52,6 +50,10 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -68,8 +70,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -107,6 +109,10 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -123,8 +129,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -162,6 +168,10 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -178,8 +188,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -203,99 +213,6 @@ public sealed class ScopedContentProcessorTests
|
||||
Assert.Equal("user-123", result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_DeduplicatesCombinedPolicyActionsByActionAndRestrictionAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Test message")
|
||||
];
|
||||
PurviewSettings settings = CreateValidPurviewSettings();
|
||||
TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
DlpActionInfo processContentAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
|
||||
DlpActionInfo duplicateScopeAction = new() { Action = DlpAction.BlockAccess, RestrictionAction = RestrictionAction.Block };
|
||||
DlpActionInfo restrictionOnlyAction = new() { RestrictionAction = RestrictionAction.Block };
|
||||
ProcessContentResponse pcResponse = new()
|
||||
{
|
||||
PolicyActions =
|
||||
[
|
||||
processContentAction
|
||||
]
|
||||
};
|
||||
ProtectionScopesResponse psResponse = new()
|
||||
{
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
],
|
||||
ExecutionMode = ExecutionMode.EvaluateInline,
|
||||
PolicyActions =
|
||||
[
|
||||
duplicateScopeAction,
|
||||
restrictionOnlyAction
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(pcResponse.PolicyActions);
|
||||
Assert.Equal(2, pcResponse.PolicyActions.Count);
|
||||
Assert.Same(processContentAction, pcResponse.PolicyActions[0]);
|
||||
Assert.Same(restrictionOnlyAction, pcResponse.PolicyActions[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckApplicableScopes_MatchesAnyLocationInScope()
|
||||
{
|
||||
// Arrange
|
||||
ProcessContentRequest pcRequest = CreateProcessContentRequest();
|
||||
ProtectionScopesResponse psResponse = new()
|
||||
{
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123"),
|
||||
new("microsoft.graph.policyLocationApplication", "different-app")
|
||||
],
|
||||
ExecutionMode = ExecutionMode.EvaluateInline
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
(bool shouldProcess, _, ExecutionMode executionMode) = ScopedContentProcessor.CheckApplicableScopes(pcRequest, psResponse);
|
||||
|
||||
// Assert
|
||||
Assert.True(shouldProcess);
|
||||
Assert.Equal(ExecutionMode.EvaluateInline, executionMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_UsesCachedProtectionScopes_WhenAvailableAsync()
|
||||
{
|
||||
@@ -362,9 +279,12 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
ScopeIdentifier = "etag-1",
|
||||
Scopes =
|
||||
[
|
||||
new()
|
||||
@@ -379,8 +299,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
@@ -416,6 +336,10 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse
|
||||
{
|
||||
Scopes =
|
||||
@@ -431,8 +355,8 @@ public sealed class ScopedContentProcessorTests
|
||||
]
|
||||
};
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -508,9 +432,13 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -539,9 +467,13 @@ public sealed class ScopedContentProcessorTests
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var psResponse = new ProtectionScopesResponse { Scopes = [] };
|
||||
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(psResponse);
|
||||
|
||||
// Act
|
||||
@@ -552,260 +484,10 @@ public sealed class ScopedContentProcessorTests
|
||||
Assert.Equal(userId, result.userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCallsProcessContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessContentResponse());
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert: ProcessContent runs in the foreground; GetProtectionScopes is queued as a background job.
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync(
|
||||
It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_ReturnsShouldBlockTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
var pcResponse = new ProcessContentResponse
|
||||
{
|
||||
PolicyActions =
|
||||
[
|
||||
new() { Action = DlpAction.BlockAccess }
|
||||
]
|
||||
};
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pcResponse);
|
||||
|
||||
// Act
|
||||
var result = await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.shouldBlock);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenScopeJobCannotQueueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<ProtectionScopesCacheKey, ProtectionScopesResponse>(
|
||||
It.IsAny<ProtectionScopesCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProtectionScopesResponse?)null);
|
||||
|
||||
this._mockChannelHandler.Setup(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()))
|
||||
.Throws(new PurviewJobException("queue unavailable"));
|
||||
|
||||
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProcessContentResponse());
|
||||
|
||||
// Act
|
||||
await this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
|
||||
|
||||
// Assert: scope warmup is attempted, and ProcessContent still runs when it can't be queued.
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Once);
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessagesAsync_WithCachedPaymentRequiredState_ThrowsPaymentRequiredAsync()
|
||||
{
|
||||
// Arrange
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new (ChatRole.User, "Test message")
|
||||
};
|
||||
var settings = CreateValidPurviewSettings();
|
||||
var tokenInfo = new TokenInfo { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" };
|
||||
this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny<CancellationToken>(), null))
|
||||
.ReturnsAsync(tokenInfo);
|
||||
|
||||
this._mockCacheProvider.Setup(x => x.GetAsync<PaymentRequiredCacheKey, PaymentRequiredCacheEntry>(
|
||||
It.IsAny<PaymentRequiredCacheKey>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PaymentRequiredCacheEntry("Payment required"));
|
||||
|
||||
// Act + Assert
|
||||
await Assert.ThrowsAsync<PurviewPaymentRequiredException>(() =>
|
||||
this._processor.ProcessMessagesAsync(
|
||||
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None));
|
||||
|
||||
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
|
||||
It.IsAny<ProcessContentRequest>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny<ScopeRetrievalJob>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Func<Channel<BackgroundJobBase>, Task>? runner = null;
|
||||
Mock<IChannelHandler> channelHandler = new();
|
||||
Mock<IPurviewClient> purviewClient = new();
|
||||
Mock<ICacheProvider> cacheProvider = new();
|
||||
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
|
||||
ProtectionScopesRequest request = new("user-123", "tenant-123")
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
]
|
||||
};
|
||||
ProtectionScopesCacheKey cacheKey = new(request);
|
||||
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
|
||||
|
||||
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
|
||||
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
|
||||
|
||||
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new PurviewPaymentRequiredException("Payment required"));
|
||||
|
||||
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
|
||||
|
||||
// Act
|
||||
Assert.NotNull(runner);
|
||||
await channel.Writer.WriteAsync(new ScopeRetrievalJob(request, cacheKey, CreateProcessContentRequest()));
|
||||
channel.Writer.Complete();
|
||||
await runner(channel);
|
||||
|
||||
// Assert
|
||||
cacheProvider.Verify(x => x.SetAsync(
|
||||
It.Is<PaymentRequiredCacheKey>(key => key.TenantId == "tenant-123"),
|
||||
It.Is<PaymentRequiredCacheEntry>(entry => entry.Message == "Payment required"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesContentActivityJobAsync()
|
||||
{
|
||||
// Arrange
|
||||
Func<Channel<BackgroundJobBase>, Task>? runner = null;
|
||||
Mock<IChannelHandler> channelHandler = new();
|
||||
Mock<IPurviewClient> purviewClient = new();
|
||||
Mock<ICacheProvider> cacheProvider = new();
|
||||
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
|
||||
ProtectionScopesRequest request = CreateProtectionScopesRequest();
|
||||
ScopeRetrievalJob job = new(request, new ProtectionScopesCacheKey(request), CreateProcessContentRequest());
|
||||
Channel<BackgroundJobBase> channel = Channel.CreateUnbounded<BackgroundJobBase>();
|
||||
|
||||
channelHandler.Setup(x => x.AddRunner(It.IsAny<Func<Channel<BackgroundJobBase>, Task>>()))
|
||||
.Callback<Func<Channel<BackgroundJobBase>, Task>>(callback => runner = callback);
|
||||
|
||||
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny<ProtectionScopesRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
|
||||
|
||||
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
|
||||
|
||||
// Act
|
||||
Assert.NotNull(runner);
|
||||
await channel.Writer.WriteAsync(job);
|
||||
channel.Writer.Complete();
|
||||
await runner(channel);
|
||||
|
||||
// Assert
|
||||
channelHandler.Verify(x => x.QueueJob(It.IsAny<ContentActivityJob>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ProtectionScopesRequest CreateProtectionScopesRequest()
|
||||
{
|
||||
return new ProtectionScopesRequest("user-123", "tenant-123")
|
||||
{
|
||||
Activities = ProtectionScopeActivities.UploadText,
|
||||
Locations =
|
||||
[
|
||||
new("microsoft.graph.policyLocationApplication", "app-123")
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static ProcessContentRequest CreateProcessContentRequest()
|
||||
{
|
||||
PurviewTextContent content = new("Test content");
|
||||
ProcessConversationMetadata metadata = new(content, "msg-123", false, "Test message", "test-correlation-id");
|
||||
ActivityMetadata activityMetadata = new(Activity.UploadText);
|
||||
DeviceMetadata deviceMetadata = new()
|
||||
{
|
||||
OperatingSystemSpecifications = new()
|
||||
{
|
||||
OperatingSystemPlatform = "Windows",
|
||||
OperatingSystemVersion = "10"
|
||||
}
|
||||
};
|
||||
IntegratedAppMetadata integratedAppMetadata = new()
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
PolicyLocation policyLocation = new("microsoft.graph.policyLocationApplication", "app-123");
|
||||
ProtectedAppMetadata protectedAppMetadata = new(policyLocation)
|
||||
{
|
||||
Name = "TestApp",
|
||||
Version = "1.0"
|
||||
};
|
||||
ContentToProcess contentToProcess = new(
|
||||
[metadata],
|
||||
activityMetadata,
|
||||
deviceMetadata,
|
||||
integratedAppMetadata,
|
||||
protectedAppMetadata);
|
||||
|
||||
return new ProcessContentRequest(contentToProcess, "user-123", "tenant-123");
|
||||
}
|
||||
|
||||
private static PurviewSettings CreateValidPurviewSettings()
|
||||
{
|
||||
return new PurviewSettings("TestApp")
|
||||
|
||||
@@ -320,5 +320,4 @@ except (PurviewAuthenticationError, PurviewRateLimitError, PurviewRequestError,
|
||||
- **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.
|
||||
- **Cold-cache parallelization**: On a `ProtectionScopes` cache miss, scopes are refreshed in the background while `ProcessContent` runs in the foreground.
|
||||
- **Background Processing**: Content Activities and offline Process Content requests are handled asynchronously using background tasks to avoid blocking the main execution flow.
|
||||
|
||||
@@ -231,19 +231,18 @@ class ScopedContentProcessor:
|
||||
cached_ps_resp = await self._cache.get(cache_key)
|
||||
|
||||
if cached_ps_resp is not None and isinstance(cached_ps_resp, ProtectionScopesResponse):
|
||||
return await self._process_with_cached_scopes(pc_request, cached_ps_resp, cache_key)
|
||||
ps_resp = cached_ps_resp
|
||||
else:
|
||||
ttl = self._settings.get("cache_ttl_seconds")
|
||||
ttl_seconds = ttl if ttl is not None else 14400
|
||||
try:
|
||||
ps_resp = await self._client.get_protection_scopes(ps_req)
|
||||
await self._cache.set(cache_key, ps_resp, ttl_seconds=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=ttl_seconds)
|
||||
raise
|
||||
|
||||
task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, pc_request))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return await self._call_process_content(pc_request, cache_key, dlp_actions=[])
|
||||
|
||||
async def _process_with_cached_scopes(
|
||||
self,
|
||||
pc_request: ProcessContentRequest,
|
||||
ps_resp: ProtectionScopesResponse,
|
||||
cache_key: str,
|
||||
) -> ProcessContentResponse:
|
||||
if ps_resp.scope_identifier:
|
||||
pc_request.scope_identifier = ps_resp.scope_identifier
|
||||
|
||||
@@ -260,7 +259,13 @@ class ScopedContentProcessor:
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
|
||||
|
||||
return await self._call_process_content(pc_request, cache_key, dlp_actions=dlp_actions)
|
||||
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(
|
||||
@@ -276,52 +281,12 @@ class ScopedContentProcessor:
|
||||
# Respond with HttpStatusCode 204(No Content)
|
||||
return ProcessContentResponse(id="204", correlation_id=pc_request.correlation_id)
|
||||
|
||||
async def _call_process_content(
|
||||
self,
|
||||
pc_request: ProcessContentRequest,
|
||||
cache_key: str,
|
||||
dlp_actions: list[DlpActionInfo],
|
||||
) -> ProcessContentResponse:
|
||||
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)
|
||||
|
||||
if dlp_actions:
|
||||
pc_resp.policy_actions = self._combine_policy_actions(pc_resp.policy_actions, dlp_actions)
|
||||
return pc_resp
|
||||
|
||||
async def _refresh_protection_scopes_background(
|
||||
self, ps_req: ProtectionScopesRequest, cache_key: str, pc_request: ProcessContentRequest
|
||||
) -> None:
|
||||
"""Fetch protection scopes and warm the cache without blocking the foreground call."""
|
||||
ttl = self._settings.get("cache_ttl_seconds")
|
||||
ttl_seconds = ttl if ttl is not None else 14400
|
||||
try:
|
||||
ps_resp = await self._client.get_protection_scopes(ps_req)
|
||||
await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds)
|
||||
should_process, _, _ = self._check_applicable_scopes(pc_request, ps_resp)
|
||||
if not should_process:
|
||||
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,
|
||||
)
|
||||
await self._send_content_activities_background(ca_req)
|
||||
except PurviewPaymentRequiredError as ex:
|
||||
tenant_payment_cache_key = f"purview:payment_required:{ps_req.tenant_id}"
|
||||
await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds)
|
||||
logger.warning("Background protection scopes refresh failed with payment required: %s", ex)
|
||||
except Exception as ex:
|
||||
logger.warning("Background protection scopes refresh failed: %s", ex)
|
||||
|
||||
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 scopes changed, invalidate cache and retry once.
|
||||
# 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)
|
||||
@@ -341,10 +306,14 @@ class ScopedContentProcessor:
|
||||
def _combine_policy_actions(
|
||||
existing: list[DlpActionInfo] | None, new_actions: list[DlpActionInfo]
|
||||
) -> list[DlpActionInfo]:
|
||||
combined: dict[tuple[DlpAction | None, RestrictionAction | None], DlpActionInfo] = {}
|
||||
for action_info in (existing or []) + new_actions:
|
||||
combined.setdefault((action_info.action, action_info.restriction_action), action_info)
|
||||
return list(combined.values())
|
||||
by_key: dict[str, DlpActionInfo] = {}
|
||||
for a in existing or []:
|
||||
if a.action:
|
||||
by_key[a.action] = a
|
||||
for a in new_actions:
|
||||
if a.action:
|
||||
by_key[a.action] = a
|
||||
return list(by_key.values())
|
||||
|
||||
@staticmethod
|
||||
def _check_applicable_scopes(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
"""Tests for Purview processor."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -218,38 +217,10 @@ class TestScopedContentProcessor:
|
||||
assert action1 in combined
|
||||
assert action2 in combined
|
||||
|
||||
async def test_combine_policy_actions_preserves_restriction_only_actions(
|
||||
self, processor: ScopedContentProcessor
|
||||
) -> None:
|
||||
"""Test _combine_policy_actions keeps actions that only set restrictionAction."""
|
||||
existing_action = DlpActionInfo(action=DlpAction.OTHER, restrictionAction=RestrictionAction.OTHER)
|
||||
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
|
||||
|
||||
combined = processor._combine_policy_actions([existing_action], [restriction_only_action])
|
||||
|
||||
assert combined == [existing_action, restriction_only_action]
|
||||
|
||||
async def test_combine_policy_actions_deduplicates_by_action_and_restriction(
|
||||
self, processor: ScopedContentProcessor
|
||||
) -> None:
|
||||
"""Test _combine_policy_actions removes exact duplicate actions."""
|
||||
block_action = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK)
|
||||
duplicate_block_action = DlpActionInfo(
|
||||
action=DlpAction.BLOCK_ACCESS, restriction_action=RestrictionAction.BLOCK
|
||||
)
|
||||
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
|
||||
|
||||
combined = processor._combine_policy_actions(
|
||||
[block_action],
|
||||
[duplicate_block_action, restriction_only_action],
|
||||
)
|
||||
|
||||
assert combined == [block_action, restriction_only_action]
|
||||
|
||||
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 process_content immediately and warms scopes in background on cache miss."""
|
||||
"""Test _process_with_scopes calls get_protection_scopes when scopes response is empty."""
|
||||
from agent_framework_purview._models import (
|
||||
ContentActivitiesResponse,
|
||||
ProtectionScopesResponse,
|
||||
@@ -265,91 +236,38 @@ class TestScopedContentProcessor:
|
||||
|
||||
response = await processor._process_with_scopes(request)
|
||||
|
||||
# On cache miss, ProcessContent runs in the foreground and the response is returned.
|
||||
assert response.id == "response-123"
|
||||
mock_client.process_content.assert_called_once()
|
||||
|
||||
# Protection scopes are refreshed in a background task.
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
mock_client.send_content_activities.assert_called_once()
|
||||
|
||||
async def test_process_with_scopes_preserves_restriction_only_policy_actions(
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test cold-cache ProcessContent actions are not dropped when they only contain restrictionAction."""
|
||||
from agent_framework_purview._models import ProtectionScopesResponse
|
||||
|
||||
request = process_content_request_factory()
|
||||
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
|
||||
|
||||
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []}))
|
||||
mock_client.process_content = AsyncMock(
|
||||
return_value=ProcessContentResponse(
|
||||
id="response-123",
|
||||
protection_scope_state="notModified",
|
||||
policy_actions=[restriction_only_action],
|
||||
)
|
||||
)
|
||||
|
||||
response = await processor._process_with_scopes(request)
|
||||
|
||||
assert response.policy_actions == [restriction_only_action]
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
|
||||
async def test_process_with_cached_scopes_preserves_restriction_only_policy_actions(
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test cached ProtectionScopes actions are not dropped when they only contain restrictionAction."""
|
||||
from agent_framework_purview._models import (
|
||||
ExecutionMode,
|
||||
PolicyLocation,
|
||||
PolicyScope,
|
||||
ProtectionScopeActivities,
|
||||
ProtectionScopesResponse,
|
||||
)
|
||||
|
||||
request = process_content_request_factory()
|
||||
restriction_only_action = DlpActionInfo(restriction_action=RestrictionAction.BLOCK)
|
||||
process_content_action = DlpActionInfo(action=DlpAction.OTHER, restriction_action=RestrictionAction.OTHER)
|
||||
scope_location = PolicyLocation(
|
||||
data_type="microsoft.graph.policyLocationApplication",
|
||||
value="app-id",
|
||||
)
|
||||
scope = PolicyScope(
|
||||
activities=ProtectionScopeActivities.UPLOAD_TEXT,
|
||||
locations=[scope_location],
|
||||
policy_actions=[restriction_only_action],
|
||||
execution_mode=ExecutionMode.EVALUATE_INLINE,
|
||||
)
|
||||
|
||||
processor._cache.get = AsyncMock(
|
||||
side_effect=[
|
||||
None,
|
||||
ProtectionScopesResponse(scope_identifier="scope-123", scopes=[scope]),
|
||||
]
|
||||
) # type: ignore[method-assign]
|
||||
mock_client.process_content = AsyncMock(
|
||||
return_value=ProcessContentResponse(
|
||||
id="response-123",
|
||||
protection_scope_state="notModified",
|
||||
policy_actions=[process_content_action],
|
||||
)
|
||||
)
|
||||
|
||||
response = await processor._process_with_scopes(request)
|
||||
|
||||
assert response.policy_actions == [process_content_action, restriction_only_action]
|
||||
# When no scopes apply, process_content is not called (activities are sent in background)
|
||||
mock_client.process_content.assert_not_called()
|
||||
# The response should have id=204 (No Content) when no scopes apply
|
||||
assert response.id == "204"
|
||||
|
||||
async def test_process_with_scopes_ignores_unexpected_cached_value_type(
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test that a corrupted cache entry does not crash processing."""
|
||||
from agent_framework_purview._models import ProtectionScopesResponse
|
||||
from agent_framework_purview._models import (
|
||||
ExecutionMode,
|
||||
PolicyLocation,
|
||||
PolicyScope,
|
||||
ProcessContentResponse,
|
||||
ProtectionScopeActivities,
|
||||
ProtectionScopesResponse,
|
||||
)
|
||||
|
||||
request = process_content_request_factory()
|
||||
|
||||
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []}))
|
||||
# Return a valid, inline scope so we stay on the normal (non-background) path.
|
||||
scope_location = PolicyLocation(**{
|
||||
"@odata.type": "microsoft.graph.policyLocationApplication",
|
||||
"value": "app-id",
|
||||
})
|
||||
scope = PolicyScope(**{
|
||||
"activities": ProtectionScopeActivities.UPLOAD_TEXT,
|
||||
"locations": [scope_location],
|
||||
"execution_mode": ExecutionMode.EVALUATE_INLINE,
|
||||
})
|
||||
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": [scope]}))
|
||||
mock_client.process_content = AsyncMock(
|
||||
return_value=ProcessContentResponse(**{"id": "ok", "protectionScopeState": "notModified"})
|
||||
)
|
||||
@@ -361,9 +279,8 @@ class TestScopedContentProcessor:
|
||||
response = await processor._process_with_scopes(request)
|
||||
|
||||
assert response.id == "ok"
|
||||
mock_client.process_content.assert_called_once()
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
mock_client.process_content.assert_called_once()
|
||||
|
||||
async def test_process_with_scopes_uses_tenant_payment_exception_cache(
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
@@ -384,6 +301,8 @@ class TestScopedContentProcessor:
|
||||
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test offline background processing invalidates cache and retries when scope state changes."""
|
||||
from agent_framework_purview._models import ProcessContentResponse
|
||||
|
||||
request = process_content_request_factory()
|
||||
request.scope_identifier = "etag-1"
|
||||
|
||||
@@ -400,36 +319,6 @@ class TestScopedContentProcessor:
|
||||
processor._cache.remove.assert_called_once_with("purview:protection_scopes:abc")
|
||||
assert mock_client.process_content.call_count == 2
|
||||
|
||||
async def test_background_scope_refresh_caches_payment_required(
|
||||
self, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""402 raised during background scope refresh is cached at the tenant level."""
|
||||
from agent_framework_purview._cache import InMemoryCacheProvider
|
||||
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
|
||||
|
||||
settings = PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
purview_app_location=PurviewAppLocation(
|
||||
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
|
||||
),
|
||||
)
|
||||
|
||||
cache = InMemoryCacheProvider()
|
||||
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache)
|
||||
|
||||
mock_client.get_protection_scopes = AsyncMock(side_effect=PurviewPaymentRequiredError("nope"))
|
||||
mock_client.process_content = AsyncMock(
|
||||
return_value=ProcessContentResponse(**{"id": "pc-1", "protectionScopeState": "notModified"})
|
||||
)
|
||||
|
||||
request = process_content_request_factory()
|
||||
await processor._process_with_scopes(request)
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
|
||||
cached = await cache.get(f"purview:payment_required:{request.tenant_id}")
|
||||
assert isinstance(cached, PurviewPaymentRequiredError)
|
||||
|
||||
async def test_map_messages_with_user_id_in_additional_properties(self, mock_client: AsyncMock) -> None:
|
||||
"""Test user_id extraction from message additional_properties."""
|
||||
settings = PurviewSettings(
|
||||
@@ -498,8 +387,6 @@ class TestScopedContentProcessor:
|
||||
self, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test that response is returned when scopes don't apply (activities sent in background)."""
|
||||
from agent_framework_purview._models import ProtectionScopesResponse
|
||||
|
||||
settings = PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
@@ -511,8 +398,10 @@ class TestScopedContentProcessor:
|
||||
|
||||
pc_request = process_content_request_factory()
|
||||
|
||||
mock_ps_response = ProtectionScopesResponse(scopes=[])
|
||||
processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign]
|
||||
# Mock get_protection_scopes to return no applicable scopes
|
||||
mock_ps_response = MagicMock()
|
||||
mock_ps_response.scopes = []
|
||||
mock_client.get_protection_scopes.return_value = mock_ps_response
|
||||
|
||||
# Mock send_content_activities to return success (called in background)
|
||||
mock_ca_response = MagicMock()
|
||||
@@ -521,10 +410,8 @@ class TestScopedContentProcessor:
|
||||
|
||||
response = await processor._process_with_scopes(pc_request)
|
||||
|
||||
mock_client.get_protection_scopes.assert_not_called()
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
mock_client.process_content.assert_not_called()
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
mock_client.send_content_activities.assert_called_once()
|
||||
# Response should have id=204 when no scopes apply
|
||||
assert response.id == "204"
|
||||
|
||||
@@ -532,8 +419,6 @@ class TestScopedContentProcessor:
|
||||
self, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test that errors in background activities don't affect the response."""
|
||||
from agent_framework_purview._models import ProtectionScopesResponse
|
||||
|
||||
settings = PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
@@ -545,8 +430,10 @@ class TestScopedContentProcessor:
|
||||
|
||||
pc_request = process_content_request_factory()
|
||||
|
||||
mock_ps_response = ProtectionScopesResponse(scopes=[])
|
||||
processor._cache.get = AsyncMock(side_effect=[None, mock_ps_response]) # type: ignore[method-assign]
|
||||
# Mock get_protection_scopes to return no applicable scopes
|
||||
mock_ps_response = MagicMock()
|
||||
mock_ps_response.scopes = []
|
||||
mock_client.get_protection_scopes.return_value = mock_ps_response
|
||||
|
||||
# Mock send_content_activities to return error (called in background task)
|
||||
mock_ca_response = MagicMock()
|
||||
@@ -558,8 +445,6 @@ class TestScopedContentProcessor:
|
||||
# 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"
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
mock_client.send_content_activities.assert_called_once()
|
||||
|
||||
|
||||
class TestUserIdResolution:
|
||||
@@ -771,12 +656,10 @@ class TestScopedContentProcessorCaching:
|
||||
mock_client.get_protection_scopes.return_value = ProtectionScopesResponse(
|
||||
scope_identifier="scope-123", scopes=[]
|
||||
)
|
||||
mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified")
|
||||
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
|
||||
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
|
||||
@@ -787,7 +670,7 @@ class TestScopedContentProcessorCaching:
|
||||
async def test_payment_required_exception_cached_at_tenant_level(
|
||||
self, mock_client: AsyncMock, settings: PurviewSettings
|
||||
) -> None:
|
||||
"""Test that background scope 402 returns once, then throws from the tenant-level cache."""
|
||||
"""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
|
||||
|
||||
@@ -795,12 +678,13 @@ class TestScopedContentProcessorCaching:
|
||||
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider)
|
||||
|
||||
mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required")
|
||||
mock_client.process_content.return_value = ProcessContentResponse(id="ok", protection_scope_state="notModified")
|
||||
|
||||
messages = [Message(role="user", contents=["Test"])]
|
||||
|
||||
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
|
||||
await asyncio.gather(*list(processor._background_tasks))
|
||||
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()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This getting-started sample shows how to attach Microsoft Purview policy evaluation to an Agent Framework `Agent` using the **middleware** approach.
|
||||
|
||||
**What this sample demonstrates:**
|
||||
1. Configure a Foundry chat client
|
||||
1. Configure an Azure OpenAI chat client
|
||||
2. Add Purview policy enforcement middleware (`PurviewPolicyMiddleware`)
|
||||
3. Add Purview policy enforcement at the chat client level (`PurviewChatPolicyMiddleware`)
|
||||
4. Implement a custom cache provider for advanced caching scenarios
|
||||
@@ -17,8 +17,8 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
|----------|----------|---------|
|
||||
| `FOUNDRY_PROJECT_ENDPOINT` | Yes | Azure AI Foundry project endpoint, for example `https://<resource>.services.ai.azure.com/api/projects/<project>` |
|
||||
| `FOUNDRY_MODEL` | Optional | Model deployment name (defaults to `gpt-4o-mini`) |
|
||||
| `AZURE_OPENAI_ENDPOINT` | Yes | Azure OpenAI endpoint (https://<name>.openai.azure.com) |
|
||||
| `AZURE_OPENAI_MODEL` | Optional | Model deployment name (defaults inside SDK if omitted) |
|
||||
| `PURVIEW_CLIENT_APP_ID` | Yes* | Client (application) ID used for Purview authentication |
|
||||
| `PURVIEW_USE_CERT_AUTH` | Optional (`true`/`false`) | Switch between certificate and interactive auth |
|
||||
| `PURVIEW_TENANT_ID` | Yes (when cert auth on) | Tenant ID for certificate authentication |
|
||||
@@ -31,8 +31,7 @@ This getting-started sample shows how to attach Microsoft Purview policy evaluat
|
||||
Opens a browser on first run to sign in.
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT = "https://<resource>.services.ai.azure.com/api/projects/<project>"
|
||||
$env:FOUNDRY_MODEL = "gpt-4o-mini"
|
||||
$env:AZURE_OPENAI_ENDPOINT = "https://your-openai-instance.openai.azure.com"
|
||||
$env:PURVIEW_CLIENT_APP_ID = "00000000-0000-0000-0000-000000000000"
|
||||
```
|
||||
|
||||
@@ -65,27 +64,22 @@ If interactive auth is used, a browser window will appear the first time.
|
||||
|
||||
## 4. How It Works
|
||||
|
||||
The sample demonstrates four integration scenarios. Each scenario runs the same three-message sequence via `run_policy_flow(...)`:
|
||||
|
||||
1. **good (cold cache)** - a benign prompt that exercises the cold-cache parallel ProtectionScopes warmup + foreground ProcessContent path.
|
||||
2. **expected block** - a sensitive prompt containing the Visa test credit card number `4111 1111 1111 1111`. If the tenant has a DLP policy for `Microsoft 365 Copilot and AI apps` targeting the Credit Card sensitive info type with a Block action, this prompt returns the configured `blocked_prompt_message` (default: `Prompt blocked by policy`). If no DLP policy applies, the prompt is allowed (the LLM may still decline on its own, but that is a model-level response, not a Purview block).
|
||||
3. **good (warm cache)** - a second benign prompt that exercises the warm-cache path. The custom cache provider scenario prints `Cache HIT` for the same protection-scopes key, confirming the cache and middleware state survive a prior block.
|
||||
The sample demonstrates three different scenarios:
|
||||
|
||||
### A. Agent Middleware (`run_with_agent_middleware`)
|
||||
1. Builds a Foundry chat client (using the environment project endpoint / deployment)
|
||||
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. Runs the three-message `good -> block -> good` orchestration
|
||||
6. Prints `ALLOWED` or `BLOCKED` per message, plus the model response
|
||||
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. Runs the same `good -> block -> good` orchestration
|
||||
5. Uses default caching automatically
|
||||
4. Uses default caching automatically
|
||||
|
||||
### C. Custom Cache Provider (`run_with_custom_cache_provider`)
|
||||
1. Implements the `CacheProvider` protocol with a custom class (`SimpleDictCacheProvider`)
|
||||
@@ -94,27 +88,9 @@ The sample demonstrates four integration scenarios. Each scenario runs the same
|
||||
- `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`
|
||||
4. Runs the `good -> block -> good` orchestration and prints `Cache MISS`/`Cache HIT` traces alongside policy outcomes, showing the cold-cache warmup populating the cache and warm-cache requests skipping ProtectionScopes.
|
||||
|
||||
### D. Default Cache (`run_with_default_cache`)
|
||||
1. Same as the agent middleware path but with explicit cache TTL and size limits in `PurviewSettings`
|
||||
2. Uses the default in-memory `CacheProvider`
|
||||
3. Runs the `good -> block -> good` orchestration
|
||||
|
||||
**Policy Behavior:**
|
||||
Prompt blocks substitute the configured `blocked_prompt_message` (default `Prompt blocked by policy`) and terminate the agent run early. Response blocks substitute `blocked_response_message`. The LLM is never called for a blocked prompt.
|
||||
|
||||
**Seeing a real `BLOCKED` outcome:**
|
||||
The middle prompt only returns `BLOCKED` if the tenant actually has a Purview DLP policy that matches the request. Specifically, all of the following must be true:
|
||||
|
||||
1. The Entra app id used by `PURVIEW_CLIENT_APP_ID` (the same id Agent Framework sends as `policyLocationApplication.value`) is registered as an integrated AI app in Purview (Settings -> AI app and agent locations).
|
||||
2. A DLP policy in the tenant targets the location `Microsoft 365 Copilot and AI apps`, scoped to that app id (or `All apps`).
|
||||
3. The policy has a rule with the condition `Content contains -> Sensitive info types -> Credit Card Number` and an action of `Restrict access to Microsoft 365 Copilot and AI apps -> Block`.
|
||||
4. The policy is `On` (not `Test mode without notifications`).
|
||||
5. The signed-in user is in the policy's user scope.
|
||||
6. Required Graph delegated permissions are admin-consented: `ProtectionScopes.Compute.All`, `Content.Process.All`, `ContentActivity.Write`.
|
||||
|
||||
If any of those are missing, the credit card prompt is allowed at the Purview layer. The model itself may still decline on its own; that response is a model-level refusal, not a Purview block. The cold/warm cache orchestration is still demonstrated either way - the `Cache MISS -> Cache HIT` trace from the custom cache scenario does not depend on a block firing.
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ Shows:
|
||||
Note: Caching is automatic and enabled by default.
|
||||
|
||||
Environment variables:
|
||||
- FOUNDRY_PROJECT_ENDPOINT (required) - Azure AI Foundry project endpoint URL
|
||||
- FOUNDRY_MODEL (optional, defaults to gpt-4o-mini)
|
||||
- AZURE_OPENAI_ENDPOINT (required)
|
||||
- AZURE_OPENAI_MODEL (optional, defaults to gpt-4o-mini)
|
||||
- PURVIEW_CLIENT_APP_ID (required)
|
||||
- PURVIEW_USE_CERT_AUTH (optional, set to "true" for certificate auth)
|
||||
- PURVIEW_TENANT_ID (required if certificate auth)
|
||||
@@ -45,37 +45,6 @@ load_dotenv()
|
||||
JOKER_NAME = "Joker"
|
||||
JOKER_INSTRUCTIONS = "You are good at telling jokes. Keep responses concise."
|
||||
|
||||
# Sequential prompts to demonstrate good -> block -> good orchestration.
|
||||
# The sensitive prompt contains a Visa test credit card number that matches Purview's
|
||||
# built-in Credit Card sensitive information type. If the tenant has a DLP policy that
|
||||
# blocks credit card content for Microsoft 365 Copilot and AI apps, the second message
|
||||
# will be blocked and the third will verify that subsequent calls still flow normally
|
||||
# after a block.
|
||||
GOOD_PROMPT_PRIMARY = "Tell me a joke about a pirate."
|
||||
SENSITIVE_PROMPT = "My corporate credit card is 4111 1111 1111 1111. Please confirm receipt."
|
||||
GOOD_PROMPT_FOLLOWUP = "Another light joke please."
|
||||
|
||||
|
||||
async def run_policy_flow(
|
||||
label: str,
|
||||
agent: Agent,
|
||||
user_id: str | None,
|
||||
blocked_text: str,
|
||||
) -> None:
|
||||
"""Run a good -> block candidate -> good sequence and report each outcome."""
|
||||
blocked_marker = blocked_text.lower()
|
||||
prompts = [
|
||||
("good (cold cache)", GOOD_PROMPT_PRIMARY),
|
||||
("expected block", SENSITIVE_PROMPT),
|
||||
("good (warm cache)", GOOD_PROMPT_FOLLOWUP),
|
||||
]
|
||||
for tag, text in prompts:
|
||||
response: AgentResponse = await agent.run(
|
||||
Message("user", [text], additional_properties={"user_id": user_id})
|
||||
)
|
||||
outcome = "BLOCKED" if blocked_marker in str(response).lower() else "ALLOWED"
|
||||
print(f"[{label}] {tag}: {outcome}\n{response}\n")
|
||||
|
||||
|
||||
# Custom Cache Provider Implementation
|
||||
class SimpleDictCacheProvider:
|
||||
@@ -169,17 +138,21 @@ def build_credential() -> Any:
|
||||
|
||||
|
||||
async def run_with_agent_middleware() -> None:
|
||||
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping run: FOUNDRY_PROJECT_ENDPOINT not set")
|
||||
print("Skipping run: AZURE_OPENAI_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
|
||||
deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential())
|
||||
client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential())
|
||||
|
||||
settings = PurviewSettings(app_name="Agent Framework Sample App")
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings)
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(
|
||||
build_credential(),
|
||||
PurviewSettings(
|
||||
app_name="Agent Framework Sample App",
|
||||
),
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
@@ -189,26 +162,39 @@ async def run_with_agent_middleware() -> None:
|
||||
)
|
||||
|
||||
print("-- Agent MiddlewareTypes Path --")
|
||||
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
|
||||
await run_policy_flow("agent middleware", agent, user_id, blocked_text)
|
||||
first: AgentResponse = await agent.run(
|
||||
Message("user", ["Tell me a joke about a pirate."], additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("First response (agent middleware):\n", first)
|
||||
|
||||
second: AgentResponse = await agent.run(
|
||||
Message(
|
||||
role="user", contents=["That was funny. Tell me another one."], additional_properties={"user_id": user_id}
|
||||
)
|
||||
)
|
||||
print("Second response (agent middleware):\n", second)
|
||||
|
||||
|
||||
async def run_with_chat_middleware() -> None:
|
||||
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping chat middleware run: FOUNDRY_PROJECT_ENDPOINT not set")
|
||||
print("Skipping chat middleware run: AZURE_OPENAI_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("FOUNDRY_MODEL", default="gpt-4o-mini")
|
||||
deployment = os.environ.get("AZURE_OPENAI_MODEL", default="gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
|
||||
settings = PurviewSettings(app_name="Agent Framework Sample App (Chat)")
|
||||
client = FoundryChatClient(
|
||||
model=deployment,
|
||||
project_endpoint=endpoint,
|
||||
endpoint=endpoint,
|
||||
credential=AzureCliCredential(),
|
||||
middleware=[
|
||||
PurviewChatPolicyMiddleware(build_credential(), settings)
|
||||
PurviewChatPolicyMiddleware(
|
||||
build_credential(),
|
||||
PurviewSettings(
|
||||
app_name="Agent Framework Sample App (Chat)",
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -219,27 +205,43 @@ async def run_with_chat_middleware() -> None:
|
||||
)
|
||||
|
||||
print("-- Chat MiddlewareTypes Path --")
|
||||
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
|
||||
await run_policy_flow("chat middleware", agent, user_id, blocked_text)
|
||||
first: AgentResponse = await agent.run(
|
||||
Message(
|
||||
role="user",
|
||||
contents=["Give me a short clean joke."],
|
||||
additional_properties={"user_id": user_id},
|
||||
)
|
||||
)
|
||||
print("First response (chat middleware):\n", first)
|
||||
|
||||
second: AgentResponse = await agent.run(
|
||||
Message(
|
||||
role="user",
|
||||
contents=["One more please."],
|
||||
additional_properties={"user_id": user_id},
|
||||
)
|
||||
)
|
||||
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("FOUNDRY_PROJECT_ENDPOINT")
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping custom cache provider run: FOUNDRY_PROJECT_ENDPOINT not set")
|
||||
print("Skipping custom cache provider run: AZURE_OPENAI_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
|
||||
deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential())
|
||||
client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential())
|
||||
|
||||
custom_cache = SimpleDictCacheProvider()
|
||||
|
||||
settings = PurviewSettings(app_name="Agent Framework Sample App (Custom Provider)")
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(
|
||||
build_credential(),
|
||||
settings,
|
||||
PurviewSettings(
|
||||
app_name="Agent Framework Sample App (Custom Provider)",
|
||||
),
|
||||
cache_provider=custom_cache,
|
||||
)
|
||||
|
||||
@@ -252,28 +254,38 @@ async def run_with_custom_cache_provider() -> None:
|
||||
|
||||
print("-- Custom Cache Provider Path --")
|
||||
print("Using SimpleDictCacheProvider")
|
||||
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
|
||||
await run_policy_flow("custom cache", agent, user_id, blocked_text)
|
||||
|
||||
first: AgentResponse = await agent.run(
|
||||
Message(
|
||||
role="user", contents=["Tell me a joke about a programmer."], additional_properties={"user_id": user_id}
|
||||
)
|
||||
)
|
||||
print("First response (custom provider):\n", first)
|
||||
|
||||
second: AgentResponse = await agent.run(
|
||||
Message("user", ["That's hilarious! One more?"], additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("Second response (custom provider):\n", second)
|
||||
|
||||
async def run_with_default_cache() -> None:
|
||||
"""Demonstrate using the default built-in cache."""
|
||||
endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
|
||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
if not endpoint:
|
||||
print("Skipping default cache run: FOUNDRY_PROJECT_ENDPOINT not set")
|
||||
print("Skipping default cache run: AZURE_OPENAI_ENDPOINT not set")
|
||||
return
|
||||
|
||||
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
|
||||
deployment = os.environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini")
|
||||
user_id = os.environ.get("PURVIEW_DEFAULT_USER_ID")
|
||||
client = FoundryChatClient(model=deployment, project_endpoint=endpoint, credential=AzureCliCredential())
|
||||
client = FoundryChatClient(model=deployment, endpoint=endpoint, credential=AzureCliCredential())
|
||||
|
||||
# No cache_provider specified - uses default InMemoryCacheProvider
|
||||
settings = PurviewSettings(
|
||||
app_name="Agent Framework Sample App (Default Cache)",
|
||||
cache_ttl_seconds=3600,
|
||||
max_cache_size_bytes=100 * 1024 * 1024, # 100MB
|
||||
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
|
||||
),
|
||||
)
|
||||
purview_agent_middleware = PurviewPolicyMiddleware(build_credential(), settings)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
@@ -284,8 +296,16 @@ async def run_with_default_cache() -> None:
|
||||
|
||||
print("-- Default Cache Path --")
|
||||
print("Using default InMemoryCacheProvider with settings-based configuration")
|
||||
blocked_text = settings.get("blocked_prompt_message") or "Prompt blocked by policy"
|
||||
await run_policy_flow("default cache", agent, user_id, blocked_text)
|
||||
|
||||
first: AgentResponse = await agent.run(
|
||||
Message("user", ["Tell me a joke about AI."], additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("First response (default cache):\n", first)
|
||||
|
||||
second: AgentResponse = await agent.run(
|
||||
Message("user", ["Nice! Another AI joke please."], additional_properties={"user_id": user_id})
|
||||
)
|
||||
print("Second response (default cache):\n", second)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -306,11 +326,6 @@ async def main() -> None:
|
||||
except Exception as ex: # pragma: no cover - demo resilience
|
||||
print(f"Custom cache provider path failed: {ex}")
|
||||
|
||||
try:
|
||||
await run_with_default_cache()
|
||||
except Exception as ex: # pragma: no cover - demo resilience
|
||||
print(f"Default cache path failed: {ex}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
Reference in New Issue
Block a user