Compare commits

..
Author SHA1 Message Date
Evan Mattson 39e6bffa3b AI triage bot initial commit for testing 2026-02-02 13:23:50 +09:00
Evan MattsonandGitHub 184ee9d518 Fix AzureAIAgentClient dropping agent instructions in sequential workflows (#3563)
In _prepare_options(), the 'instructions' key was excluded from run_options
but never re-added. This caused instructions passed via as_agent(instructions=...)
to be silently dropped, making agents in sequential workflows ignore their
configured instructions.

Fixes #3507
2026-02-01 09:05:35 +00:00
Rishabh ChawlaandGitHub 2f7250fe0f Python: Add tests to Purview Package (#3513)
* Add tests to increase code coverage

* Add tests to increase code coverage
2026-01-30 23:08:39 +00:00
Dmytro StrukandGitHub 493891620d Python: Replaced obsolete create_response method in samples (#3542)
* Replaced obsolete create_response method in samples

* Addressed PR comments
2026-01-30 22:22:00 +00:00
Giles OdigweandGitHub f3e0be9555 Python: Disable mem0 telemetry by default (#3506)
* disable mem0 telemetry by default

* test fix

* addressed comments
2026-01-30 21:17:52 +00:00
25 changed files with 772 additions and 53 deletions
+118
View File
@@ -0,0 +1,118 @@
name: AI Issue Triage
on:
issues:
types: [opened]
discussion:
types: [created]
workflow_dispatch:
inputs:
reference:
description: 'Reference (e.g., "issue:123" or "discussion:456")'
required: true
type: string
live_mode:
description: 'Enable live mode'
required: false
type: boolean
default: false
concurrency:
group: triage-${{ github.event.issue.number || github.event.discussion.number || github.event.inputs.reference }}
cancel-in-progress: false
permissions:
issues: write
discussions: write
contents: read
jobs:
security-check:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
reference: ${{ steps.check.outputs.reference }}
steps:
- name: Validate
id: check
run: |
if [[ "${{ github.actor }}" == *"[bot]"* ]] || [[ "${{ github.event.repository.fork }}" == "true" ]]; then
echo "should_run=false" >> $GITHUB_OUTPUT
exit 0
fi
if [[ "${{ github.event_name }}" == "issues" ]]; then
REF="issue:${{ github.event.issue.number }}"
elif [[ "${{ github.event_name }}" == "discussion" ]]; then
REF="discussion:${{ github.event.discussion.number }}"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
REF="${{ github.event.inputs.reference }}"
else
echo "should_run=false" >> $GITHUB_OUTPUT
exit 0
fi
if ! [[ "$REF" =~ ^(issue|discussion):[0-9]+$ ]]; then
echo "should_run=false" >> $GITHUB_OUTPUT
exit 1
fi
echo "should_run=true" >> $GITHUB_OUTPUT
echo "reference=$REF" >> $GITHUB_OUTPUT
triage:
needs: security-check
if: needs.security-check.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
env:
UV_CACHE_DIR: /tmp/.uv-cache
TRIAGE_LIVE: ${{ github.event.inputs.live_mode || 'false' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: |
python
.github/actions
sparse-checkout-cone-mode: false
- name: Setup
run: git clone https://${{ secrets.TRIAGE_BOT_REPO_TOKEN }}@github.com/${{ vars.TRIAGE_BOT_REPO }}.git triage-bot
env:
GIT_TERMINAL_PROMPT: 0
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "python/**/uv.lock"
- name: Install
working-directory: python
run: uv sync --package agent-framework-core --extra openai
- name: Prepare
run: docker pull ghcr.io/github/github-mcp-server
- name: Run
working-directory: python
env:
GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
TRIAGE_REFERENCE: ${{ needs.security-check.outputs.reference }}
TRIAGE_BOT_PATH: ${{ github.workspace }}/triage-bot
run: |
echo "::add-mask::$GITHUB_PERSONAL_ACCESS_TOKEN"
echo "::add-mask::$OPENAI_API_KEY"
uv run python $TRIAGE_BOT_PATH/run.py
- name: Summary
if: always()
run: |
echo "## Triage Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Reference:** ${{ needs.security-check.outputs.reference }}" >> $GITHUB_STEP_SUMMARY
echo "- **Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
@@ -948,6 +948,10 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
if additional_messages:
run_options["additional_messages"] = additional_messages
# Add instructions from options (agent's instructions set via as_agent())
if options_instructions := options.get("instructions"):
instructions.append(options_instructions)
# Add instruction from existing agent at the beginning
if (
agent_definition is not None
@@ -467,6 +467,56 @@ async def test_azure_ai_chat_client_prepare_options_with_messages(mock_agents_cl
assert len(run_options["additional_messages"]) == 1 # Only user message
async def test_azure_ai_chat_client_prepare_options_with_instructions_from_options(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_options includes instructions passed via options.
This verifies that agent instructions set via as_agent(instructions=...)
are properly included in the API call.
"""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
mock_agents_client.get_agent = AsyncMock(return_value=None)
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options: ChatOptions = {
"instructions": "You are a thoughtful reviewer. Give brief feedback.",
}
run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore
assert "instructions" in run_options
assert "reviewer" in run_options["instructions"].lower()
async def test_azure_ai_chat_client_prepare_options_merges_instructions_from_messages_and_options(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_options merges instructions from both system messages and options.
When instructions come from both system/developer messages AND from options,
both should be included in the final instructions.
"""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
mock_agents_client.get_agent = AsyncMock(return_value=None)
messages = [
ChatMessage(role=Role.SYSTEM, text="Context: You are reviewing marketing copy."),
ChatMessage(role=Role.USER, text="Review this tagline"),
]
chat_options: ChatOptions = {
"instructions": "Be concise and constructive in your feedback.",
}
run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore
assert "instructions" in run_options
instructions_text = run_options["instructions"]
# Both instruction sources should be present
assert "marketing" in instructions_text.lower()
assert "concise" in instructions_text.lower()
async def test_azure_ai_chat_client_inner_get_response(mock_agents_client: MagicMock) -> None:
"""Test _inner_get_response method."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
+11
View File
@@ -18,3 +18,14 @@ See the [Mem0 basic example](https://github.com/microsoft/agent-framework/tree/m
- Teaching the agent user preferences
- Retrieving information using remembered context across new threads
- Persistent memory
## Telemetry
Mem0's telemetry is **disabled by default** when using this package. If you want to enable telemetry, set the environment variable before importing:
```python
import os
os.environ["MEM0_TELEMETRY"] = "true"
from agent_framework.mem0 import Mem0Provider
```
@@ -1,6 +1,12 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
import os
# Disable Mem0 telemetry by default to prevent usage data from being sent to telemetry provider.
# Users can opt-in by setting MEM0_TELEMETRY=true before importing this package.
if os.environ.get("MEM0_TELEMETRY") is None:
os.environ["MEM0_TELEMETRY"] = "false"
from ._provider import Mem0Provider
@@ -30,7 +30,13 @@ MemorySearchResponse_v2 = list[dict[str, Any]]
class Mem0Provider(ContextProvider):
"""Mem0 Context Provider."""
"""Mem0 Context Provider.
Note:
Mem0's telemetry is disabled by default when using this package.
To enable telemetry, set the environment variable ``MEM0_TELEMETRY=true`` before
importing this package.
"""
def __init__(
self,
@@ -1,6 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportPrivateUsage=false
import importlib
import os
import sys
from unittest.mock import AsyncMock, patch
import pytest
@@ -592,3 +595,43 @@ class TestMem0ProviderBuildFilters:
filters = provider._build_filters()
assert filters == {}
class TestMem0Telemetry:
"""Test telemetry configuration for Mem0."""
def test_mem0_telemetry_disabled_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that MEM0_TELEMETRY is set to 'false' by default when importing the package."""
# Ensure MEM0_TELEMETRY is not set before importing the module under test
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
# Remove cached modules to force re-import and trigger module-level initialization
modules_to_remove = [key for key in sys.modules if key.startswith("agent_framework_mem0")]
for mod in modules_to_remove:
del sys.modules[mod]
# Import (and reload) the module so that it can set MEM0_TELEMETRY when unset
import agent_framework_mem0
importlib.reload(agent_framework_mem0)
# The environment variable should be set to "false" after importing
assert os.environ.get("MEM0_TELEMETRY") == "false"
def test_mem0_telemetry_respects_user_setting(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that user-set MEM0_TELEMETRY value is not overwritten."""
# Remove cached modules to force re-import
modules_to_remove = [key for key in sys.modules if key.startswith("agent_framework_mem0")]
for mod in modules_to_remove:
del sys.modules[mod]
# Set user preference before import
monkeypatch.setenv("MEM0_TELEMETRY", "true")
# Re-import the module
import agent_framework_mem0
importlib.reload(agent_framework_mem0)
# User setting should be preserved
assert os.environ.get("MEM0_TELEMETRY") == "true"
@@ -211,9 +211,8 @@ class ScopedContentProcessor:
cache_key = create_protection_scopes_cache_key(ps_req)
cached_ps_resp = await self._cache.get(cache_key)
if cached_ps_resp is not None:
if isinstance(cached_ps_resp, ProtectionScopesResponse):
ps_resp = cached_ps_resp
if cached_ps_resp is not None and isinstance(cached_ps_resp, ProtectionScopesResponse):
ps_resp = cached_ps_resp
else:
try:
ps_resp = await self._client.get_protection_scopes(ps_req)
@@ -119,6 +119,25 @@ class TestInMemoryCacheProvider:
assert result == obj
async def test_estimate_size_conservative_fallback_when_all_size_methods_fail(self, monkeypatch) -> None:
"""Test that the cache returns a conservative size estimate when all strategies fail."""
cache = InMemoryCacheProvider()
class BadString:
def __str__(self) -> str:
raise RuntimeError("boom")
def raise_getsizeof(_: object) -> int:
raise RuntimeError("no sizeof")
monkeypatch.setattr("agent_framework_purview._cache.sys.getsizeof", raise_getsizeof)
# Arrange/Act
size = cache._estimate_size(BadString())
# Assert
assert size == 1024
async def test_cache_multiple_updates(self) -> None:
"""Test that updating a key multiple times maintains correct size tracking."""
cache = InMemoryCacheProvider(max_size_bytes=1000)
@@ -204,6 +204,39 @@ class TestPurviewChatPolicyMiddleware:
with pytest.raises(PurviewPaymentRequiredError):
await middleware.process(context, mock_next)
async def test_chat_middleware_handles_payment_required_post_check(self, mock_credential: AsyncMock) -> None:
"""Test that 402 in post-check is raised when ignore_payment_required=False."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
settings = PurviewSettings(app_name="Test App", ignore_payment_required=False)
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], options=chat_options
)
call_count = 0
async def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return (False, "user-123")
raise PurviewPaymentRequiredError("Payment required")
with patch.object(middleware._processor, "process_messages", side_effect=side_effect):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="OK")]
ctx.result = result
with pytest.raises(PurviewPaymentRequiredError):
await middleware.process(context, mock_next)
async def test_chat_middleware_ignores_payment_required_when_configured(self, mock_credential: AsyncMock) -> None:
"""Test that 402 is ignored when ignore_payment_required=True."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
@@ -274,3 +307,58 @@ class TestPurviewChatPolicyMiddleware:
await middleware.process(context, mock_next)
# Next should have been called
assert context.result is not None
async def test_chat_middleware_raises_on_pre_check_exception_when_ignore_exceptions_false(
self, mock_credential: AsyncMock
) -> None:
"""Test that exceptions are propagated by default when ignore_exceptions=False."""
settings = PurviewSettings(app_name="Test App", ignore_exceptions=False)
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], options=chat_options
)
with patch.object(middleware._processor, "process_messages", side_effect=ValueError("boom")):
async def mock_next(_: ChatContext) -> None:
raise AssertionError("next should not be called")
with pytest.raises(ValueError, match="boom"):
await middleware.process(context, mock_next)
async def test_chat_middleware_raises_on_post_check_exception_when_ignore_exceptions_false(
self, mock_credential: AsyncMock
) -> None:
"""Test that post-check exceptions are propagated by default."""
settings = PurviewSettings(app_name="Test App", ignore_exceptions=False)
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], options=chat_options
)
call_count = 0
async def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return (False, "user-123")
raise ValueError("post")
with patch.object(middleware._processor, "process_messages", side_effect=side_effect):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="OK")]
ctx.result = result
with pytest.raises(ValueError, match="post"):
await middleware.process(context, mock_next)
+215 -1
View File
@@ -2,6 +2,7 @@
"""Tests for Purview client."""
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@@ -18,6 +19,8 @@ from agent_framework_purview._exceptions import (
PurviewServiceError,
)
from agent_framework_purview._models import (
ContentActivitiesRequest,
ContentActivitiesResponse,
PolicyLocation,
ProcessContentRequest,
ProtectionScopesRequest,
@@ -47,7 +50,9 @@ class TestPurviewClient:
return PurviewSettings(app_name="Test App", tenant_id="test-tenant", default_user_id="test-user")
@pytest.fixture
async def client(self, mock_credential: MagicMock, settings: PurviewSettings) -> PurviewClient:
async def client(
self, mock_credential: MagicMock, settings: PurviewSettings
) -> AsyncGenerator[PurviewClient, None]:
"""Create a PurviewClient with mock credential."""
client = PurviewClient(mock_credential, settings, timeout=10.0)
yield client
@@ -185,6 +190,215 @@ class TestPurviewClient:
assert response.scope_identifier == "scope-123"
assert response.scopes == []
async def test_get_protection_scopes_uses_etag_header_when_present(self, client: PurviewClient) -> None:
"""Test that get_protection_scopes prefers the HTTP ETag header when present."""
from agent_framework_purview._models import ProtectionScopesResponse
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request = ProtectionScopesRequest(
user_id="user-123", tenant_id="tenant-456", locations=[location], correlation_id="corr-789"
)
response_obj = ProtectionScopesResponse(**{"scopeIdentifier": "scope-from-body", "value": []})
with patch.object(
client,
"_post",
return_value=(response_obj, {"etag": '"etag-from-header"'}),
):
response = await client.get_protection_scopes(request)
assert response.scope_identifier == "etag-from-header"
async def test_post_402_returns_empty_response_when_ignore_payment_required_enabled(
self, mock_credential: MagicMock
) -> None:
"""Test that 402 is suppressed when ignore_payment_required=True."""
from agent_framework_purview._models import ProcessContentResponse
settings = PurviewSettings(app_name="Test App", ignore_payment_required=True)
client = PurviewClient(mock_credential, settings)
request = ProcessContentRequest(user_id="user-123", tenant_id="tenant-456", content_to_process=[])
resp = httpx.Response(402, text="Payment required", request=httpx.Request("POST", "http://test"))
with patch.object(client._client, "post", return_value=resp):
result = await client._post("http://test", request, ProcessContentResponse, token="fake-token")
assert isinstance(result, ProcessContentResponse)
await client.close()
async def test_post_sets_request_and_response_correlation_id(self, client: PurviewClient) -> None:
"""Test that correlation_id is injected into request headers and hydrated from response headers."""
from agent_framework_purview._models import ProcessContentResponse
# correlation_id is optional and should be auto-generated when empty
request = ProcessContentRequest(user_id="user-123", tenant_id="tenant-456", content_to_process=[])
request.correlation_id = "" # force auto-generation branch
captured_headers: dict[str, str] = {}
async def fake_post(url: str, json=None, headers=None):
nonlocal captured_headers
captured_headers = dict(headers or {})
return httpx.Response(
200,
json={"id": "resp-1", "protectionScopeState": "notModified"},
headers={"client-request-id": "corr-from-response"},
request=httpx.Request("POST", url),
)
with patch.object(client._client, "post", side_effect=fake_post):
result_obj, result_headers = await client._post(
"http://test",
request,
ProcessContentResponse,
token="fake-token",
return_response=True,
)
assert "client-request-id" in captured_headers
assert captured_headers["client-request-id"]
assert result_headers["client-request-id"] == "corr-from-response"
assert result_obj.correlation_id == "corr-from-response"
async def test_process_content_402_returns_empty_when_ignored(self, mock_credential: MagicMock) -> None:
"""Test that process_content returns an empty response (non-tuple path) when 402 is ignored."""
from agent_framework_purview._models import ProcessContentResponse
settings = PurviewSettings(app_name="Test App", ignore_payment_required=True)
client = PurviewClient(mock_credential, settings)
req = ProcessContentRequest(user_id="user-123", tenant_id="tenant-456", content_to_process=[])
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 402
mock_response.text = "Payment required"
with patch.object(client._client, "post", return_value=mock_response):
response = await client.process_content(req)
assert isinstance(response, ProcessContentResponse)
await client.close()
async def test_post_sets_correlation_id_attribute_on_recording_span(self, client: PurviewClient) -> None:
"""Test that correlation_id is added to the active span when recording is enabled."""
from agent_framework_purview._models import ProcessContentResponse
request = ProcessContentRequest(user_id="user-123", tenant_id="tenant-456", content_to_process=[])
request.correlation_id = "corr-123"
class RecordingSpan:
def __init__(self) -> None:
self.attributes: dict[str, str] = {}
def is_recording(self) -> bool:
return True
def set_attribute(self, key: str, value: str) -> None:
self.attributes[key] = value
span = RecordingSpan()
with (
patch("agent_framework_purview._client.trace.get_current_span", return_value=span),
patch.object(
client._client,
"post",
return_value=httpx.Response(
200,
json={"id": "resp-1", "protectionScopeState": "notModified"},
headers={},
request=httpx.Request("POST", "http://test"),
),
),
):
await client._post("http://test", request, ProcessContentResponse, token="fake-token")
assert span.attributes["correlation_id"] == "corr-123"
async def test_post_uses_constructor_when_response_type_has_no_model_validate(self, client: PurviewClient) -> None:
"""Test that _post falls back to the response type constructor when model_validate is absent."""
class DummyResponse:
def __init__(self, **data):
self.data = data
request = ProcessContentRequest(user_id="user-123", tenant_id="tenant-456", content_to_process=[])
request.correlation_id = "corr-123"
with patch.object(
client._client,
"post",
return_value=httpx.Response(
200,
json={"hello": "world"},
headers={},
request=httpx.Request("POST", "http://test"),
),
):
result = await client._post("http://test", request, DummyResponse, token="fake-token")
assert isinstance(result, DummyResponse)
assert result.data == {"hello": "world"}
async def test_send_content_activities_success(self, client: PurviewClient, content_to_process_factory) -> None:
"""Test send_content_activities success path."""
request = ContentActivitiesRequest(
user_id="user-123",
tenant_id="tenant-456",
content_to_process=content_to_process_factory("hello"),
correlation_id="corr-1",
)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {"error": None}
with patch.object(client._client, "post", return_value=mock_response):
resp = await client.send_content_activities(request)
assert isinstance(resp, ContentActivitiesResponse)
async def test_post_handles_invalid_json_response_body(self, client: PurviewClient) -> None:
"""Test that invalid JSON bodies fall back to an empty dict."""
request = ProcessContentRequest(user_id="user-123", tenant_id="tenant-456", content_to_process=[])
request.correlation_id = "corr-123"
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.side_effect = ValueError("not json")
with patch.object(client._client, "post", return_value=mock_response):
result = await client._post("http://test", request, ContentActivitiesResponse, token="fake-token")
assert isinstance(result, ContentActivitiesResponse)
async def test_post_deserialization_failure_raises_purview_service_error(self, client: PurviewClient) -> None:
"""Test that response deserialization errors are wrapped as PurviewServiceError."""
class BadResponseType:
@classmethod
def model_validate(cls, value):
raise RuntimeError("boom")
request = ProcessContentRequest(user_id="user-123", tenant_id="tenant-456", content_to_process=[])
request.correlation_id = "corr-123"
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {"any": "data"}
with (
patch.object(client._client, "post", return_value=mock_response),
pytest.raises(PurviewServiceError, match="Failed to deserialize Purview response"),
):
await client._post("http://test", request, BadResponseType, token="fake-token")
async def test_client_close(self, mock_credential: AsyncMock, settings: PurviewSettings) -> None:
"""Test client properly closes HTTP client."""
client = PurviewClient(mock_credential, settings)
@@ -153,6 +153,92 @@ class TestPurviewPolicyMiddleware:
for call in mock_process.call_args_list:
assert call[0][1] == Activity.UPLOAD_TEXT
async def test_middleware_streaming_skips_post_check(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that streaming results skip post-check evaluation."""
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello")])
context.is_streaming = True
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc:
async def mock_next(ctx: AgentRunContext) -> None:
ctx.result = AgentResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="streaming")])
await middleware.process(context, mock_next)
assert mock_proc.call_count == 1
async def test_middleware_payment_required_in_pre_check_raises_by_default(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that 402 in pre-check is raised when ignore_payment_required=False."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello")])
with patch.object(
middleware._processor,
"process_messages",
side_effect=PurviewPaymentRequiredError("Payment required"),
):
async def mock_next(_: AgentRunContext) -> None:
raise AssertionError("next should not be called")
with pytest.raises(PurviewPaymentRequiredError):
await middleware.process(context, mock_next)
async def test_middleware_payment_required_in_post_check_raises_by_default(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that 402 in post-check is raised when ignore_payment_required=False."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello")])
call_count = 0
async def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return (False, "user-123")
raise PurviewPaymentRequiredError("Payment required")
with patch.object(middleware._processor, "process_messages", side_effect=side_effect):
async def mock_next(ctx: AgentRunContext) -> None:
ctx.result = AgentResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="OK")])
with pytest.raises(PurviewPaymentRequiredError):
await middleware.process(context, mock_next)
async def test_middleware_post_check_exception_raises_when_ignore_exceptions_false(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that post-check exceptions are propagated when ignore_exceptions=False."""
middleware._settings.ignore_exceptions = False
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello")])
call_count = 0
async def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return (False, "user-123")
raise ValueError("Post-check blew up")
with patch.object(middleware._processor, "process_messages", side_effect=side_effect):
async def mock_next(ctx: AgentRunContext) -> None:
ctx.result = AgentResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="OK")])
with pytest.raises(ValueError, match="Post-check blew up"):
await middleware.process(context, mock_next)
async def test_middleware_handles_pre_check_exception(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
@@ -242,6 +242,83 @@ class TestScopedContentProcessor:
# 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 (
ExecutionMode,
PolicyLocation,
PolicyScope,
ProcessContentResponse,
ProtectionScopeActivities,
ProtectionScopesResponse,
)
request = process_content_request_factory()
# 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"})
)
# First cache read is the tenant payment key (None). Second is the scopes cache (corrupt value).
processor._cache.get = AsyncMock(side_effect=[None, "corrupt-value"]) # type: ignore[method-assign]
processor._cache.set = AsyncMock() # type: ignore[method-assign]
response = await processor._process_with_scopes(request)
assert response.id == "ok"
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
) -> None:
"""Test that a cached 402 exception short-circuits all subsequent requests for the tenant."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
request = process_content_request_factory()
processor._cache.get = AsyncMock(return_value=PurviewPaymentRequiredError("Payment required")) # type: ignore[method-assign]
with pytest.raises(PurviewPaymentRequiredError):
await processor._process_with_scopes(request)
mock_client.get_protection_scopes.assert_not_called()
async def test_process_content_background_retries_on_modified_state(
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"
mock_client.process_content = AsyncMock(
side_effect=[
ProcessContentResponse(**{"id": "r1", "protectionScopeState": "modified"}),
ProcessContentResponse(**{"id": "r2", "protectionScopeState": "notModified"}),
]
)
processor._cache.remove = AsyncMock() # type: ignore[method-assign]
await processor._process_content_background(request, cache_key="purview:protection_scopes:abc")
processor._cache.remove.assert_called_once_with("purview:protection_scopes:abc")
assert mock_client.process_content.call_count == 2
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(
@@ -28,7 +28,7 @@ async def handle_approvals_without_thread(query: str, agent: "AgentProtocol") ->
new_inputs.append(ChatMessage(role="assistant", contents=[user_input_needed]))
user_approval = input("Approve function call? (y/n): ")
new_inputs.append(
ChatMessage(role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")])
ChatMessage(role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")])
)
result = await agent.run(new_inputs, store=False)
@@ -50,7 +50,7 @@ async def handle_approvals_with_thread(query: str, agent: "AgentProtocol", threa
new_input.append(
ChatMessage(
role="user",
contents=[user_input_needed.create_response(user_approval.lower() == "y")],
contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")],
)
)
result = await agent.run(new_input, thread=thread)
@@ -31,7 +31,7 @@ async def handle_approvals_with_thread(query: str, agent: "AgentProtocol", threa
new_input.append(
ChatMessage(
role="user",
contents=[user_input_needed.create_response(user_approval.lower() == "y")],
contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")],
)
)
result = await agent.run(new_input, thread=thread, store=True)
@@ -59,7 +59,7 @@ async def handle_approvals_with_thread(query: str, agent: "AgentProtocol", threa
new_input.append(
ChatMessage(
role="user",
contents=[user_input_needed.create_response(user_approval.lower() == "y")],
contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")],
)
)
result = await agent.run(new_input, thread=thread, store=True)
@@ -33,7 +33,7 @@ async def handle_approvals_without_thread(query: str, agent: "AgentProtocol"):
new_inputs.append(ChatMessage(role="assistant", contents=[user_input_needed]))
user_approval = input("Approve function call? (y/n): ")
new_inputs.append(
ChatMessage(role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")])
ChatMessage(role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")])
)
result = await agent.run(new_inputs)
@@ -56,7 +56,7 @@ async def handle_approvals_with_thread(query: str, agent: "AgentProtocol", threa
new_input.append(
ChatMessage(
role="user",
contents=[user_input_needed.create_response(user_approval.lower() == "y")],
contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")],
)
)
result = await agent.run(new_input, thread=thread, store=True)
@@ -82,7 +82,7 @@ async def handle_approvals_with_thread_streaming(query: str, agent: "AgentProtoc
user_approval = input("Approve function call? (y/n): ")
new_input.append(
ChatMessage(
role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")]
role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")]
)
)
new_input_added = True
@@ -32,7 +32,7 @@ async def handle_approvals_without_thread(query: str, agent: "AgentProtocol"):
new_inputs.append(ChatMessage(role="assistant", contents=[user_input_needed]))
user_approval = input("Approve function call? (y/n): ")
new_inputs.append(
ChatMessage(role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")])
ChatMessage(role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")])
)
result = await agent.run(new_inputs)
@@ -55,7 +55,7 @@ async def handle_approvals_with_thread(query: str, agent: "AgentProtocol", threa
new_input.append(
ChatMessage(
role="user",
contents=[user_input_needed.create_response(user_approval.lower() == "y")],
contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")],
)
)
result = await agent.run(new_input, thread=thread, store=True)
@@ -81,7 +81,7 @@ async def handle_approvals_with_thread_streaming(query: str, agent: "AgentProtoc
user_approval = input("Approve function call? (y/n): ")
new_input.append(
ChatMessage(
role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")]
role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")]
)
)
new_input_added = True
@@ -66,7 +66,7 @@ async def handle_approvals(query: str, agent: "AgentProtocol") -> AgentResponse:
# Add the user's approval response
new_inputs.append(
ChatMessage(role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")])
ChatMessage(role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")])
)
# Run again with all the context
@@ -116,7 +116,7 @@ async def handle_approvals_streaming(query: str, agent: "AgentProtocol") -> None
# Add the user's approval response
new_inputs.append(
ChatMessage(role="user", contents=[user_input_needed.create_response(user_approval.lower() == "y")])
ChatMessage(role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")])
)
# Update input with all the context for next iteration
@@ -54,7 +54,7 @@ async def approval_example() -> None:
print(f" Decision: {'Approved' if approved else 'Rejected'}")
# Step 2: Send approval response
approval_response = request.create_response(approved=approved)
approval_response = request.to_function_approval_response(approved=approved)
result = await agent.run(ChatMessage(role="user", contents=[approval_response]), thread=thread)
print(f"Agent: {result}\n")
@@ -87,7 +87,7 @@ async def rejection_example() -> None:
print(" Decision: Rejected")
# Send rejection response
rejection_response = request.create_response(approved=False)
rejection_response = request.to_function_approval_response(approved=False)
result = await agent.run(ChatMessage(role="user", contents=[rejection_response]), thread=thread)
print(f"Agent: {result}\n")
@@ -9,8 +9,8 @@ from typing import cast
from agent_framework import (
ChatAgent,
ChatMessage,
Content,
FileCheckpointStorage,
FunctionApprovalRequestContent,
HandoffBuilder,
HandoffUserInputRequest,
RequestInfoEvent,
@@ -26,7 +26,7 @@ from azure.identity import AzureCliCredential
Sample: Handoff Workflow with Tool Approvals + Checkpoint Resume
Demonstrates the two-step pattern for resuming a handoff workflow from a checkpoint
while handling both HandoffUserInputRequest prompts and FunctionApprovalRequestContent
while handling both HandoffUserInputRequest prompts and function approval request Content
for tool calls (e.g., submit_refund).
Scenario:
@@ -137,7 +137,7 @@ def _print_handoff_request(request: HandoffUserInputRequest, request_id: str) ->
print(f"{'=' * 60}\n")
def _print_function_approval_request(request: FunctionApprovalRequestContent, request_id: str) -> None:
def _print_function_approval_request(request: Content, request_id: str) -> None:
"""Log pending tool approval details for debugging."""
args = request.function_call.parse_arguments() or {}
print(f"\n{'=' * 60}")
@@ -161,10 +161,10 @@ def _build_responses_for_requests(
if user_response is None:
raise ValueError("User response is required for HandoffUserInputRequest")
responses[request.request_id] = user_response
elif isinstance(request.data, FunctionApprovalRequestContent):
elif isinstance(request.data, Content) and request.data.type == "function_approval_request":
if approve_tools is None:
raise ValueError("Approval decision is required for FunctionApprovalRequestContent")
responses[request.request_id] = request.data.create_response(approved=approve_tools)
raise ValueError("Approval decision is required for function approval request")
responses[request.request_id] = request.data.to_function_approval_response(approved=approve_tools)
else:
raise ValueError(f"Unsupported request type: {type(request.data)}")
return responses
@@ -201,7 +201,7 @@ async def run_until_user_input_needed(
pending_requests.append(event)
if isinstance(event.data, HandoffUserInputRequest):
_print_handoff_request(event.data, event.request_id)
elif isinstance(event.data, FunctionApprovalRequestContent):
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
_print_function_approval_request(event.data, event.request_id)
elif isinstance(event, WorkflowOutputEvent):
@@ -258,7 +258,7 @@ async def resume_with_responses(
restored_requests.append(event)
if isinstance(event.data, HandoffUserInputRequest):
_print_handoff_request(event.data, event.request_id)
elif isinstance(event.data, FunctionApprovalRequestContent):
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
_print_function_approval_request(event.data, event.request_id)
if not restored_requests:
@@ -291,7 +291,7 @@ async def resume_with_responses(
new_pending_requests.append(event)
if isinstance(event.data, HandoffUserInputRequest):
_print_handoff_request(event.data, event.request_id)
elif isinstance(event.data, FunctionApprovalRequestContent):
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
_print_function_approval_request(event.data, event.request_id)
return new_pending_requests, latest_checkpoint.checkpoint_id
@@ -362,7 +362,7 @@ async def main() -> None:
workflow_step, _, _, _ = create_workflow(checkpoint_storage=storage)
needs_user_input = any(isinstance(req.data, HandoffUserInputRequest) for req in pending_requests)
needs_tool_approval = any(isinstance(req.data, FunctionApprovalRequestContent) for req in pending_requests)
needs_tool_approval = any(isinstance(req.data, Content) and req.data.type == "function_approval_request" for req in pending_requests)
user_response = None
if needs_user_input:
@@ -9,9 +9,8 @@ from agent_framework import (
AgentExecutorResponse,
ChatAgent,
ChatMessage,
Content,
Executor,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
WorkflowBuilder,
WorkflowContext,
tool,
@@ -251,7 +250,7 @@ async def main() -> None:
body="Please provide your team's status update on the project since last week.",
)
responses: dict[str, FunctionApprovalResponseContent] = {}
responses: dict[str, Content] = {}
output: list[ChatMessage] | None = None
while True:
if responses:
@@ -262,8 +261,8 @@ async def main() -> None:
request_info_events = events.get_request_info_events()
for request_info_event in request_info_events:
# We should only expect FunctionApprovalRequestContent in this sample
if not isinstance(request_info_event.data, FunctionApprovalRequestContent):
# We should only expect function_approval_request Content in this sample
if not isinstance(request_info_event.data, Content) or request_info_event.data.type != "function_approval_request":
raise ValueError(f"Unexpected request info content type: {type(request_info_event.data)}")
# Pretty print the function call details
@@ -274,10 +273,10 @@ async def main() -> None:
)
# For demo purposes, we automatically approve the request
# The expected response type of the request is `FunctionApprovalResponseContent`,
# which can be created via `create_response` method on the request content
# The expected response type of the request is `function_approval_response Content`,
# which can be created via `to_function_approval_response` method on the request content
print("Performing automatic approval for demo purposes...")
responses[request_info_event.request_id] = request_info_event.data.create_response(approved=True)
responses[request_info_event.request_id] = request_info_event.data.to_function_approval_response(approved=True)
# Once we get an output event, we can conclude the workflow
# Outputs can only be produced by the conclude_workflow_executor in this sample
@@ -6,8 +6,7 @@ from typing import Annotated
from agent_framework import (
ChatMessage,
ConcurrentBuilder,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
Content,
RequestInfoEvent,
WorkflowOutputEvent,
tool,
@@ -139,7 +138,7 @@ async def main() -> None:
):
if isinstance(event, RequestInfoEvent):
request_info_events.append(event)
if isinstance(event.data, FunctionApprovalRequestContent):
if isinstance(event.data, Content) and event.data.type == "function_approval_request":
print(f"\nApproval requested for tool: {event.data.function_call.name}")
print(f" Arguments: {event.data.function_call.arguments}")
elif isinstance(event, WorkflowOutputEvent):
@@ -147,12 +146,12 @@ async def main() -> None:
# 6. Handle approval requests (if any)
if request_info_events:
responses: dict[str, FunctionApprovalResponseContent] = {}
responses: dict[str, Content] = {}
for request_event in request_info_events:
if isinstance(request_event.data, FunctionApprovalRequestContent):
if isinstance(request_event.data, Content) and request_event.data.type == "function_approval_request":
print(f"\nSimulating human approval for: {request_event.data.function_call.name}")
# Create approval response
responses[request_event.request_id] = request_event.data.create_response(approved=True)
responses[request_event.request_id] = request_event.data.to_function_approval_response(approved=True)
if responses:
# Phase 2: Send all approvals and continue workflow
@@ -5,7 +5,7 @@ from typing import Annotated
from agent_framework import (
AgentRunUpdateEvent,
FunctionApprovalRequestContent,
Content,
GroupChatBuilder,
GroupChatRequestSentEvent,
GroupChatState,
@@ -144,7 +144,7 @@ async def main() -> None:
):
if isinstance(event, RequestInfoEvent):
request_info_events.append(event)
if isinstance(event.data, FunctionApprovalRequestContent):
if isinstance(event.data, Content) and event.data.type == "function_approval_request":
print("\n[APPROVAL REQUIRED] From agent:", event.source_executor_id)
print(f" Tool: {event.data.function_call.name}")
print(f" Arguments: {event.data.function_call.arguments}")
@@ -164,7 +164,7 @@ async def main() -> None:
# 6. Handle approval requests
if request_info_events:
for request_event in request_info_events:
if isinstance(request_event.data, FunctionApprovalRequestContent):
if isinstance(request_event.data, Content) and request_event.data.type == "function_approval_request":
print("\n" + "=" * 60)
print("Human review required for production deployment!")
print("In a real scenario, you would review the deployment details here.")
@@ -172,7 +172,7 @@ async def main() -> None:
print("=" * 60)
# Create approval response
approval_response = request_event.data.create_response(approved=True)
approval_response = request_event.data.to_function_approval_response(approved=True)
# Phase 2: Send approval and continue workflow
# Keep track of the response to format output nicely in streaming mode
@@ -5,7 +5,7 @@ from typing import Annotated
from agent_framework import (
ChatMessage,
FunctionApprovalRequestContent,
Content,
RequestInfoEvent,
SequentialBuilder,
WorkflowOutputEvent,
@@ -23,7 +23,7 @@ with approval_mode="always_require" to trigger human-in-the-loop interactions.
This sample works as follows:
1. A SequentialBuilder workflow is created with a single agent that has tools requiring approval.
2. The agent receives a user task and determines it needs to call a sensitive tool.
3. The tool call triggers a FunctionApprovalRequestContent, pausing the workflow.
3. The tool call triggers a function_approval_request Content, pausing the workflow.
4. The sample simulates human approval by responding to the RequestInfoEvent.
5. Once approved, the tool executes and the agent completes its response.
6. The workflow outputs the final conversation with all messages.
@@ -34,7 +34,7 @@ requiring any additional builder configuration.
Demonstrate:
- Using @tool(approval_mode="always_require") for sensitive operations.
- Handling RequestInfoEvent with FunctionApprovalRequestContent in sequential workflows.
- Handling RequestInfoEvent with function_approval_request Content in sequential workflows.
- Resuming workflow execution after approval via send_responses_streaming.
Prerequisites:
@@ -92,19 +92,19 @@ async def main() -> None:
):
if isinstance(event, RequestInfoEvent):
request_info_events.append(event)
if isinstance(event.data, FunctionApprovalRequestContent):
if isinstance(event.data, Content) and event.data.type == "function_approval_request":
print(f"\nApproval requested for tool: {event.data.function_call.name}")
print(f" Arguments: {event.data.function_call.arguments}")
# 5. Handle approval requests
if request_info_events:
for request_event in request_info_events:
if isinstance(request_event.data, FunctionApprovalRequestContent):
if isinstance(request_event.data, Content) and request_event.data.type == "function_approval_request":
# In a real application, you would prompt the user here
print("\nSimulating human approval (auto-approving for demo)...")
# Create approval response
approval_response = request_event.data.create_response(approved=True)
approval_response = request_event.data.to_function_approval_response(approved=True)
# Phase 2: Send approval and continue workflow
output: list[ChatMessage] | None = None