Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Purview/PurviewExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ private static PurviewWrapper CreateWrapper(TokenCredential tokenCredential, Pur
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>The updated <see cref="AIAgentBuilder"/></returns>
/// <remarks>
/// This evaluates the run boundary: the messages passed to the agent, and the agent's final response.
/// Content produced while the run executes, such as context provider output or a function call the
/// model returns, is only visible in the final response, after any function has already been invoked.
/// Use the <see cref="ChatClientBuilder"/> overload to evaluate every model round trip.
/// </remarks>
public static AIAgentBuilder WithPurview(this AIAgentBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
{
PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
Expand All @@ -71,6 +77,14 @@ public static AIAgentBuilder WithPurview(this AIAgentBuilder builder, TokenCrede
/// <param name="logger">The logger to use for logging.</param>
/// <param name="cache">The distributed cache to use for caching Purview responses. An in memory cache will be used if this is null.</param>
/// <returns>The updated <see cref="ChatClientBuilder"/></returns>
/// <remarks>
/// This evaluates every model round trip, including a function call the model returns before that
/// function is invoked. That holds only while this client is composed below
/// <see cref="FunctionInvokingChatClient"/>. Because <see cref="ChatClientBuilder"/> applies the first
/// <c>Use</c> outermost, call <c>WithPurview</c> after <c>UseFunctionInvocation</c>, or omit
/// <c>UseFunctionInvocation</c> and let the agent add it above this client. Calling <c>WithPurview</c>
/// before <c>UseFunctionInvocation</c> places this client outermost and is not corrected automatically.
/// </remarks>
public static ChatClientBuilder WithPurview(this ChatClientBuilder builder, TokenCredential tokenCredential, PurviewSettings purviewSettings, ILogger? logger = null, IDistributedCache? cache = null)
{
PurviewWrapper purviewWrapper = CreateWrapper(tokenCredential, purviewSettings, logger, cache);
Expand Down
53 changes: 51 additions & 2 deletions dotnet/src/Microsoft.Agents.AI.Purview/PurviewWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,55 @@ public PurviewWrapper(IScopedContentProcessor scopedProcessor, PurviewSettings p
this._backgroundJobRunner = backgroundJobRunner;
}

/// <summary>
/// Builds the replacement response returned when policy blocks a response that was produced.
/// </summary>
/// <param name="evaluated">The response that was evaluated and blocked.</param>
/// <param name="blockedMessage">The notice to return in place of the blocked content.</param>
/// <returns>A response carrying the original control metadata with only the messages replaced.</returns>
/// <remarks>
/// The control fields are carried over so the caller can still identify the call and resume it;
/// only the messages are replaced. <see cref="ChatResponse.RawRepresentation"/> is deliberately
/// not carried, because it holds the content that was blocked.
/// </remarks>
private static ChatResponse BuildBlockedResponse(ChatResponse evaluated, string blockedMessage) =>
new(new ChatMessage(ChatRole.System, blockedMessage))
{
ResponseId = evaluated.ResponseId,
ConversationId = evaluated.ConversationId,
ModelId = evaluated.ModelId,
CreatedAt = evaluated.CreatedAt,
FinishReason = evaluated.FinishReason,
Usage = evaluated.Usage,
ContinuationToken = evaluated.ContinuationToken,
AdditionalProperties = evaluated.AdditionalProperties,
};

/// <summary>
/// Builds the replacement response returned when policy blocks a response that was produced.
/// </summary>
/// <param name="evaluated">The response that was evaluated and blocked.</param>
/// <param name="blockedMessage">The notice to return in place of the blocked content.</param>
/// <returns>A response carrying the original control metadata with only the messages replaced.</returns>
/// <remarks>
/// The control fields are carried over so the caller can still identify the run and resume it;
/// only the messages are replaced. <see cref="AgentResponse.RawRepresentation"/> is deliberately
/// not carried, because it holds the content that was blocked.
/// </remarks>
private static AgentResponse BuildBlockedResponse(AgentResponse evaluated, string blockedMessage) =>
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
new(new ChatMessage(ChatRole.System, blockedMessage))
{
ResponseId = evaluated.ResponseId,
AgentId = evaluated.AgentId,
CreatedAt = evaluated.CreatedAt,
FinishReason = evaluated.FinishReason,
Usage = evaluated.Usage,
ContinuationToken = evaluated.ContinuationToken,
AdditionalProperties = evaluated.AdditionalProperties,
};
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.

private static string GetSessionIdFromAgentSession(AgentSession? session, IEnumerable<ChatMessage> messages)
{
if (session is ChatClientAgentSession chatClientAgentSession &&
Expand Down Expand Up @@ -107,7 +156,7 @@ public async Task<ChatResponse> ProcessChatContentAsync(IEnumerable<ChatMessage>
this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage);
}

return new ChatResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage));
return BuildBlockedResponse(response, this._purviewSettings.BlockedResponseMessage);
}
}
catch (Exception ex)
Expand Down Expand Up @@ -197,7 +246,7 @@ public async Task<AgentResponse> ProcessAgentContentAsync(IEnumerable<ChatMessag
this._logger.LogInformation("Response blocked by policy. Sending message: {Message}", this._purviewSettings.BlockedResponseMessage);
}

return new AgentResponse(new ChatMessage(ChatRole.System, this._purviewSettings.BlockedResponseMessage));
return BuildBlockedResponse(response, this._purviewSettings.BlockedResponseMessage);
}
}
catch (Exception ex)
Expand Down
87 changes: 86 additions & 1 deletion dotnet/src/Microsoft.Agents.AI.Purview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,22 @@ var settings = new PurviewSettings("My Sample App")

### Selecting Agent vs Chat Middleware

Both middlewares apply the same policy logic, but they are handed different content, so they do not evaluate the same thing. **Prefer the chat middleware for data loss prevention**; use the agent middleware when a single check at the run boundary is what you want.

| | Agent middleware | Chat middleware |
| --- | --- | --- |
| Caller's input messages | evaluated | evaluated |
| Context provider output (for example retrieval results or memory) | not evaluated | evaluated |
| Conversation history replayed into the request | not evaluated | evaluated |
| A model's tool call, before the tool executes | not evaluated | evaluated |
| Tool results | evaluated at the end of the run | evaluated on the next request |
| Final response | evaluated | evaluated |
| How often it evaluates | once per run | once per model request |

The agent middleware receives the messages passed to the agent. Content added while the run executes — context provider output, replayed history, and the function calls and results produced by `FunctionInvokingChatClient` — is assembled downstream of it, so it is visible only in the final response, after any tool has already run.

The chat middleware receives the fully prepared request on every model round trip, provided it is composed **below** `FunctionInvokingChatClient` (see [Composition order](#composition-order)). A function call returned by the model is then evaluated before that function executes, and its result is evaluated on the following round trip.

Use the agent middleware when you already have / want the full agent pipeline:

``` csharp
Expand All @@ -217,7 +233,36 @@ IChatClient client = new OpenAIClient(
.Build();
```

The policy logic is identical; the only difference is the hook point in the pipeline.
Both middlewares can be attached at the same time. The chat middleware then evaluates each model round trip and the agent middleware evaluates the run boundary.

### Composition order

`WithPurview` on an `IChatClient` must end up **below** `FunctionInvokingChatClient`, otherwise the middleware sees only the run boundary and function calls execute before it evaluates them.

``` csharp
// Correct: the agent adds function invocation above the Purview-wrapped client.
IChatClient client = chatClient
.AsBuilder()
.WithPurview(credential, settings)
.Build();
AIAgent agent = client.AsAIAgent("You are a helpful assistant.");

// Correct: the first Use is outermost, so function invocation stays above Purview.
IChatClient client = chatClient
.AsBuilder()
.UseFunctionInvocation()
.WithPurview(credential, settings)
.Build();

// Not recommended: Purview is outermost, so it evaluates only the completed run.
IChatClient client = chatClient
.AsBuilder()
.WithPurview(credential, settings)
.UseFunctionInvocation()
.Build();
```

The last form is not corrected automatically. When a chat client is turned into an agent, the framework only adds a `FunctionInvokingChatClient` if the pipeline does not already expose one, and the Purview client delegates that lookup inward — so an existing instance is found and the ordering is preserved as written.

---

Expand All @@ -230,6 +275,34 @@ 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.

### Blocked content and conversation history

Where Purview sits in the pipeline decides whether a blocked response can still be written
to conversation history.

**Chat-client level** (`WithPurview` on a `ChatClientBuilder`, or `PurviewChatClient`) —
the response is evaluated and replaced inside `GetResponseAsync`, before the agent stores
the turn, so the replacement is what becomes durable and the model's own content never
reaches the history provider. This holds as long as Purview is composed below any chat
client that stores history itself, which is the same ordering rule described above for
function invocation.

**Agent level** (`WithPurview` on an `AIAgentBuilder`, or `PurviewAgent`) — the agent
stores the turn as part of the run, and Purview wraps the whole run, so the response is
evaluated after the turn has already been written. A blocked response is replaced for the
caller, but the original content can still be read back from history on a later turn. Use
the chat-client level composition where history must not retain blocked content.

**Service-managed history** — both of the above describe history the framework stores.
Some chat clients instead keep the conversation on the service and return an id to
continue from, for example a Responses-style API called with `store` enabled. There the
service records the prompt and the response as part of the model call, before the response
comes back to be evaluated, and the agent's session advances to that turn so the next run
resumes from a conversation that still contains the blocked content. Purview still replaces
what the caller receives, but the service's copy is outside the framework's reach and
cannot be withdrawn. Where blocked content must not be retained, turn service-side storage
off so the framework owns the conversation, and use a `ChatHistoryProvider`.

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.

Expand Down Expand Up @@ -309,3 +382,15 @@ Every content item on a message is submitted for evaluation, not just its text:
as Purview binary content, and `FunctionCallContent`, `FunctionResultContent` and other structured
content are serialized to text. Only `UsageContent` is skipped, because it carries token counts rather
than user data.

### Remote references are not dereferenced

Purview classifies the content it is handed; a reference to content is not the content.

Content that carries its own bytes is evaluated as bytes: `DataContent` is submitted as Purview binary
content.

A *remote* reference is not. A `UriContent`, a hosted file reference, or a link nested inside a
`FunctionResultContent` is submitted as the reference itself, and the bytes it points at are never
fetched or evaluated. A host that needs those bytes evaluated must resolve them and pass the resolved
content through the middleware.
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Purview.Models.Common;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;

namespace Microsoft.Agents.AI.Purview.UnitTests;

/// <summary>
/// Pins what becomes durable history when Purview is composed at the chat-client level.
/// The agent persists the response its chat client returned, and Purview has already
/// replaced a blocked response by then, so the replacement is what is stored and the
/// model's own content never reaches the history provider.
/// </summary>
public sealed class PurviewDurableHistoryTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ChatLevelBlockedResponseDoesNotBecomeDurableHistoryAsync(bool streaming)
{
// Arrange
var provider = new RecordingHistoryProvider();
var agent = CreateAgent(promptBlocked: false, responseBlocked: true, provider);
var session = await agent.CreateSessionAsync();

// Act
var text = await RunAsync(agent, session, streaming);

// Assert
Assert.Contains("Response blocked by policy", text);
Assert.DoesNotContain(provider.Stored, message => message.Text.Contains("Sensitive response"));
Assert.Contains(provider.Stored, message => message.Text.Contains("Response blocked by policy"));
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ChatLevelAllowedResponseStillBecomesDurableHistoryAsync(bool streaming)
{
// Arrange
var provider = new RecordingHistoryProvider();
var agent = CreateAgent(promptBlocked: false, responseBlocked: false, provider);
var session = await agent.CreateSessionAsync();

// Act
var text = await RunAsync(agent, session, streaming);

// Assert
Assert.Contains("Sensitive response", text);
Assert.Contains(provider.Stored, message => message.Text.Contains("Sensitive response"));
}

private static async Task<string> RunAsync(AIAgent agent, AgentSession session, bool streaming)
{
var message = new ChatMessage(ChatRole.User, "Test message");
if (!streaming)
{
return (await agent.RunAsync(message, session)).Text;
}

var text = string.Empty;
await foreach (var update in agent.RunStreamingAsync(message, session))
{
text += update.Text;
}

return text;
}

private static ChatClientAgent CreateAgent(bool promptBlocked, bool responseBlocked, ChatHistoryProvider provider)
{
var settings = new PurviewSettings("TestApp")
{
TenantId = "tenant-123",
PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123"),
BlockedPromptMessage = "Prompt blocked by policy",
BlockedResponseMessage = "Response blocked by policy"
};

var processor = new Mock<IScopedContentProcessor>();
processor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
Activity.UploadText,
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((promptBlocked, "user-123"));
processor.Setup(x => x.ProcessMessagesAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<string>(),
Activity.DownloadText,
It.IsAny<PurviewSettings>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((responseBlocked, "user-123"));

var wrapper = new PurviewWrapper(processor.Object, settings, NullLogger.Instance, Mock.Of<IBackgroundJobRunner>());

var innerClient = new Mock<IChatClient>();
innerClient.Setup(x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")));

return new ChatClientAgent(
new PurviewChatClient(innerClient.Object, wrapper),
new ChatClientAgentOptions { ChatHistoryProvider = provider });
}

/// <summary>A chat history provider that records exactly what becomes durable.</summary>
private sealed class RecordingHistoryProvider : ChatHistoryProvider
{
public List<ChatMessage> Stored { get; } = [];

protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) =>
new([.. this.Stored]);

protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
this.Stored.AddRange(context.RequestMessages);
this.Stored.AddRange(context.ResponseMessages ?? []);
return default;
}
}
}
Loading
Loading