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 | |
|---|---|---|---|
|
|
39e6bffa3b | ||
|
|
184ee9d518 | ||
|
|
2f7250fe0f | ||
|
|
493891620d | ||
|
|
f3e0be9555 |
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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")
|
||||
|
||||
+10
-10
@@ -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:
|
||||
|
||||
+7
-8
@@ -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
|
||||
|
||||
+5
-6
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user