From 7936d9192c2b7732c8259b41c1e23cf262197a09 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:28:51 +0000 Subject: [PATCH 1/2] Also include citations and error details in purview checks. --- .../src/Microsoft.Agents.AI.Purview/README.md | 3 +- .../ScopedContentProcessor.cs | 28 +++++++++++ .../ScopedContentProcessorTests.cs | 50 +++++++++++++++++++ python/packages/purview/README.md | 2 +- .../agent_framework_purview/_processor.py | 9 +++- .../purview/tests/purview/test_processor.py | 16 ++++++ 6 files changed, 105 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/README.md b/dotnet/src/Microsoft.Agents.AI.Purview/README.md index 5f7ee3d9b2f..f89546be2fd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Purview/README.md @@ -381,7 +381,8 @@ disables enforcement for every error, not just transient ones. Every content item on a message is submitted for evaluation, not just its text: `DataContent` is sent 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. +than user data. Text annotations such as citations, `ErrorContent.Details` and `DataContent.Name` +are submitted as additional text entries. ### Remote references are not dereferenced diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index a456f2d7891..2b31528b663 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -278,11 +278,39 @@ private static List MapMessageContents(ChatMessage message) { mapped.Add(purviewContent); } + + AddSupplementalContent(content, mapped); } return mapped; } + /// + /// Add entries for user or model supplied data that does not carry: + /// annotations (for example citations), error details and the name of a data item. + /// + private static void AddSupplementalContent(AIContent content, List mapped) + { + switch (content) + { + case TextContent or TextReasoningContent when content.Annotations is { Count: > 0 }: + foreach (AIAnnotation annotation in content.Annotations) + { + mapped.Add(new PurviewTextContent(JsonSerializer.Serialize(annotation, AIJsonUtilities.DefaultOptions.GetTypeInfo(annotation.GetType())))); + } + + break; + + case ErrorContent { Details: { Length: > 0 } details }: + mapped.Add(new PurviewTextContent(details)); + break; + + case DataContent { Name: { Length: > 0 } name, Data.IsEmpty: false }: + mapped.Add(new PurviewTextContent(name)); + break; + } + } + /// /// Map a single onto a Purview content item. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index 7afad015ca1..0ca5453b2e5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -1214,6 +1214,56 @@ await this._processor.ProcessMessagesAsync( Assert.NotEqual(binaryEntry.Identifier, functionCallEntry.Identifier); } + /// + /// Verifies annotations, error details and data names are submitted alongside the main payload. + /// + [Fact] + public async Task ProcessMessagesAsync_WithAnnotationsErrorDetailsAndDataName_SubmitsThemForEvaluationAsync() + { + // Arrange + List messages = + [ + new(ChatRole.Assistant, + [ + new TextContent("see sources") { Annotations = [new CitationAnnotation { Title = "cited-title-111", Url = new Uri("https://example.com/cited") }] }, + new ErrorContent("failed") { Details = "error-details-222" }, + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream") { Name = "file-name-333.bin" } + ]) + ]; + PurviewSettings settings = CreateValidPurviewSettings(); + TokenInfo tokenInfo = new() { TenantId = "tenant-123", UserId = "user-123", ClientId = "client-123" }; + + this._mockPurviewClient.Setup(x => x.GetUserInfoFromTokenAsync(It.IsAny(), null)) + .ReturnsAsync(tokenInfo); + this._mockCacheProvider.Setup(x => x.GetAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateApplicableProtectionScopesResponse()); + + List capturedRequests = []; + this._mockPurviewClient.Setup(x => x.ProcessContentAsync( + It.IsAny(), It.IsAny())) + .Callback((request, _) => capturedRequests.Add(request)) + .ReturnsAsync(new ProcessContentResponse()); + + // Act + await this._processor.ProcessMessagesAsync( + messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); + + // Assert + List texts = []; + foreach (ProcessContentRequest request in capturedRequests) + { + if (request.ContentToProcess.ContentEntries[0].Content is PurviewTextContent text) + { + texts.Add(text.Data); + } + } + + Assert.Contains(texts, t => t.Contains("cited-title-111", StringComparison.Ordinal)); + Assert.Contains("error-details-222", texts); + Assert.Contains("file-name-333.bin", texts); + } + [Fact] public async Task ProcessMessagesAsync_WithEmptyTextAndData_SkipsEmptyContentAsync() { diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index d25e84bf695..ba44ca26d85 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -468,7 +468,7 @@ disables enforcement for every error, not just transient ones. Every content item on a message is submitted for evaluation, not just its text: binary/data content is sent as Purview binary content, and function calls, function results and other structured content are serialized to text. Only `usage` content is skipped, because it carries token counts rather than user -data. +data. Annotations on text content, such as citations, are submitted as additional text entries. Evaluation covers the response as it stands when the middleware runs. Because middleware can replace the response afterwards, what is evaluated depends on where the middleware is attached — see diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index 919e97723e3..e26a3abee8b 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -137,7 +137,14 @@ def _map_content(content: Content) -> ContentBase | None: def _map_message_contents(message: Message) -> list[ContentBase]: """Map every non-empty content item of a message to Purview content entries.""" - return [purview_content for content in message.contents if (purview_content := _map_content(content))] + mapped: list[ContentBase] = [] + for content in message.contents: + if purview_content := _map_content(content): + mapped.append(purview_content) + # Annotations (for example citations) are not part of the text, so they are evaluated separately. + if content.type in ("text", "text_reasoning") and content.annotations: + mapped.extend(PurviewTextContent(data=_serialize_for_evaluation(a)) for a in content.annotations) + return mapped def _is_blocking_action(action_info: DlpActionInfo) -> bool: diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index f5d2fe81efb..1743d4ec6e7 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -193,6 +193,22 @@ async def test_map_messages_submits_each_content_item_in_separate_request( assert all(entry.content is not None for entry in entries) assert not any(isinstance(entry.content, PurviewTextContent) and entry.content.data == "" for entry in entries) + async def test_map_messages_submits_annotations_for_evaluation(self, processor: ScopedContentProcessor) -> None: + """Test annotations on text content are submitted in addition to the text.""" + from agent_framework import Content + + content = Content.from_text( + "see sources", + annotations=[{"type": "citation", "title": "cited-title-111", "url": "https://example.com/cited"}], + ) + messages = [Message(role="assistant", contents=[content])] + + requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT) + + texts = [r.content_to_process.content_entries[0].content.data for r in requests] + assert "see sources" in texts + assert any("cited-title-111" in text for text in texts) + async def test_map_messages_skips_empty_text_and_data(self, processor: ScopedContentProcessor) -> None: """Test _map_messages does not create requests for empty text or binary data.""" from agent_framework import Content From 16b4984f4fafa1f73b880b931d7a3d9169ce4606 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:44:48 +0000 Subject: [PATCH 2/2] Address PR comments --- .../Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs | 2 +- .../ScopedContentProcessorTests.cs | 4 +++- python/packages/purview/tests/purview/test_processor.py | 7 ++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index 2b31528b663..2848d97db93 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -305,7 +305,7 @@ private static void AddSupplementalContent(AIContent content, List mapped.Add(new PurviewTextContent(details)); break; - case DataContent { Name: { Length: > 0 } name, Data.IsEmpty: false }: + case DataContent { Name: { Length: > 0 } name }: mapped.Add(new PurviewTextContent(name)); break; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index 0ca5453b2e5..c3df179d740 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -1227,7 +1227,8 @@ public async Task ProcessMessagesAsync_WithAnnotationsErrorDetailsAndDataName_Su [ new TextContent("see sources") { Annotations = [new CitationAnnotation { Title = "cited-title-111", Url = new Uri("https://example.com/cited") }] }, new ErrorContent("failed") { Details = "error-details-222" }, - new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream") { Name = "file-name-333.bin" } + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream") { Name = "file-name-333.bin" }, + new DataContent(ReadOnlyMemory.Empty, "application/octet-stream") { Name = "empty-file-name-444.bin" } ]) ]; PurviewSettings settings = CreateValidPurviewSettings(); @@ -1262,6 +1263,7 @@ await this._processor.ProcessMessagesAsync( Assert.Contains(texts, t => t.Contains("cited-title-111", StringComparison.Ordinal)); Assert.Contains("error-details-222", texts); Assert.Contains("file-name-333.bin", texts); + Assert.Contains("empty-file-name-444.bin", texts); } [Fact] diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index 1743d4ec6e7..70f83f4704c 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -205,7 +205,12 @@ async def test_map_messages_submits_annotations_for_evaluation(self, processor: requests, _ = await processor._map_messages(messages, Activity.UPLOAD_TEXT) - texts = [r.content_to_process.content_entries[0].content.data for r in requests] + texts = [ + entry.content.data + for r in requests + for entry in r.content_to_process.content_entries + if isinstance(entry.content, PurviewTextContent) + ] assert "see sources" in texts assert any("cited-title-111" in text for text in texts)