Python: add feature-usage User-Agent telemetry (#7420)
* Python: add first-pass feature usage telemetry Add the 128-bit feature accumulator, package-local indexes, activation markers, and destination-scoped User-Agent emission for the initial Python implementation slice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f * Python: track declarative feature usage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f * Python: complete feature usage telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f * Python: report core version in User-Agent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f * Python: configure Lab telemetry import path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f * Python: preserve telemetry transport behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f * Python: preserve caller-owned Foundry transports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f * Python: remove stale Anthropic test import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f --------- Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f
This commit is contained in:
committed by
GitHub
parent
962b86ddbb
commit
b64a2e2f82
@@ -19,8 +19,10 @@ from agent_framework import (
|
||||
Message,
|
||||
SupportsAgentRun,
|
||||
)
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from typing_extensions import override
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._utils import get_uri_data
|
||||
|
||||
logger = logging.getLogger("agent_framework.a2a")
|
||||
@@ -147,6 +149,7 @@ class A2AExecutor(AgentExecutor):
|
||||
if context.message is None:
|
||||
raise ValueError("Message must be provided in the RequestContext")
|
||||
|
||||
mark_feature_used(FeatureIndex.A2A)
|
||||
query = context.get_user_input()
|
||||
task = context.current_task
|
||||
|
||||
|
||||
@@ -42,10 +42,12 @@ from agent_framework import (
|
||||
normalize_messages,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework._types import AgentRunInputs
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._utils import get_uri_data
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -542,6 +544,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
session: The agent session for context providers.
|
||||
session_context: The session context for context providers.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.A2A)
|
||||
|
||||
if session_context is None:
|
||||
session_context = SessionContext(input_messages=[], options={})
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""A2A-owned feature-usage indexes."""
|
||||
|
||||
A2A = 71
|
||||
@@ -7,9 +7,11 @@ from typing import Any, cast
|
||||
|
||||
from ag_ui.core import BaseEvent
|
||||
from agent_framework import SupportsAgentRun
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
|
||||
from ._agent_run import PendingApprovalEntry, PendingApprovalKey, run_agent_stream
|
||||
from ._approval_state import InMemoryAGUIApprovalStateStore
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._snapshots import AGUIThreadSnapshotStore
|
||||
|
||||
|
||||
@@ -142,6 +144,7 @@ class AgentFrameworkAgent:
|
||||
Yields:
|
||||
AG-UI events
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.AG_UI)
|
||||
async for event in run_agent_stream(
|
||||
input_data,
|
||||
self.agent,
|
||||
|
||||
@@ -24,10 +24,12 @@ from agent_framework import (
|
||||
ResponseStream,
|
||||
)
|
||||
from agent_framework._middleware import ChatMiddlewareLayer
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
|
||||
from ._event_converters import AGUIEventConverter
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._http_service import AGUIHttpService, _serialize_available_interrupts, _serialize_resume
|
||||
from ._message_adapters import agent_framework_messages_to_agui
|
||||
from ._utils import convert_tools_to_agui_format
|
||||
@@ -398,6 +400,7 @@ class AGUIChatClient(
|
||||
Yields:
|
||||
ChatResponseUpdate objects
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.AG_UI)
|
||||
messages_to_send, state = self._extract_state_from_messages(messages)
|
||||
|
||||
thread_id = self._get_thread_id(options)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""AG-UI-owned feature-usage indexes."""
|
||||
|
||||
AG_UI = 72
|
||||
@@ -25,7 +25,9 @@ from ag_ui.core import (
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import Workflow
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._message_adapters import agui_messages_to_snapshot_format
|
||||
from ._run_common import (
|
||||
_build_run_finished_event,
|
||||
@@ -298,6 +300,7 @@ class AgentFrameworkWorkflow:
|
||||
|
||||
Subclasses may override this to provide custom AG-UI streams.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.AG_UI)
|
||||
thread_id = self._thread_id_from_input(input_data)
|
||||
run_id = str(input_data.get("run_id") or input_data.get("runId") or uuid.uuid4())
|
||||
snapshot_scope = cast(str | None, input_data.get(_SNAPSHOT_SCOPE_INPUT_KEY))
|
||||
|
||||
@@ -28,7 +28,7 @@ from agent_framework import (
|
||||
tool,
|
||||
)
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import get_user_agent, mark_feature_used
|
||||
from agent_framework._tools import SHELL_TOOL_KIND_VALUE, normalize_tools
|
||||
from agent_framework._types import _get_data_bytes_as_str # type: ignore
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
@@ -55,6 +55,8 @@ from anthropic.types.beta.beta_code_execution_tool_result_error import (
|
||||
from anthropic.types.beta.beta_encrypted_code_execution_result_block import BetaEncryptedCodeExecutionResultBlock
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import TypedDict # pragma: no cover
|
||||
else:
|
||||
@@ -550,6 +552,7 @@ class RawAnthropicClient(
|
||||
# each message_delta carries the running total), so thread a per-stream
|
||||
# accumulator to _process_stream_event to emit increments instead.
|
||||
emitted_usage: dict[str, int] = {}
|
||||
mark_feature_used(FeatureIndex.ANTHROPIC)
|
||||
async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True):
|
||||
parsed_chunk = self._process_stream_event(chunk, emitted_usage)
|
||||
if parsed_chunk:
|
||||
@@ -559,6 +562,7 @@ class RawAnthropicClient(
|
||||
|
||||
# Non-streaming mode
|
||||
async def _get_response() -> ChatResponse:
|
||||
mark_feature_used(FeatureIndex.ANTHROPIC)
|
||||
message = await self.anthropic_client.beta.messages.create(**run_options, stream=False)
|
||||
return self._process_message(message, options)
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Anthropic-owned feature-usage indexes."""
|
||||
|
||||
ANTHROPIC = 57
|
||||
@@ -16,7 +16,6 @@ from agent_framework import (
|
||||
FunctionInvocationLayer,
|
||||
Message,
|
||||
SupportsChatGetResponse,
|
||||
UsageDetails,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
@@ -33,6 +32,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_anthropic import AnthropicClient, RawAnthropicClient
|
||||
from agent_framework_anthropic._chat_client import AnthropicSettings
|
||||
from agent_framework_anthropic._feature_usage import FeatureIndex
|
||||
|
||||
# Test constants
|
||||
VALID_PNG_BASE64 = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
@@ -1606,10 +1606,12 @@ async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None:
|
||||
messages = [Message(role="user", contents=["Hi"])]
|
||||
chat_options = ChatOptions(max_tokens=10)
|
||||
|
||||
response = await client._inner_get_response( # type: ignore[attr-defined]
|
||||
messages=messages, options=chat_options
|
||||
)
|
||||
with patch("agent_framework_anthropic._chat_client.mark_feature_used") as mark_feature_used:
|
||||
response = await client._inner_get_response( # type: ignore[attr-defined]
|
||||
messages=messages, options=chat_options
|
||||
)
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.ANTHROPIC)
|
||||
assert response is not None
|
||||
assert response.response_id == "msg_test"
|
||||
assert len(response.messages) == 1
|
||||
|
||||
+4
-1
@@ -25,7 +25,7 @@ from agent_framework import (
|
||||
SupportsGetEmbeddings,
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import get_user_agent, mark_feature_used
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
from azure.core.credentials import AzureKeyCredential, TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
@@ -47,6 +47,8 @@ from azure.search.documents.models import (
|
||||
VectorizedQuery,
|
||||
)
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework._agents import SupportsAgentRun
|
||||
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
|
||||
@@ -626,6 +628,7 @@ class AzureAISearchContextProvider(ContextProvider):
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Retrieve relevant context from Azure AI Search and add to session context."""
|
||||
mark_feature_used(FeatureIndex.AZURE_AI_SEARCH)
|
||||
messages_list = list(context.input_messages)
|
||||
|
||||
filtered_messages = [
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Azure AI Search-owned feature-usage indexes."""
|
||||
|
||||
AZURE_AI_SEARCH = 65
|
||||
@@ -20,6 +20,7 @@ from agent_framework_azure_ai_search._context_provider import (
|
||||
KnowledgeBaseOutputModeLiteral,
|
||||
RetrievalReasoningEffortLiteral,
|
||||
)
|
||||
from agent_framework_azure_ai_search._feature_usage import FeatureIndex
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
@@ -54,6 +55,17 @@ class MockSearchResults:
|
||||
return doc
|
||||
|
||||
|
||||
async def test_before_run_marks_azure_ai_search_used() -> None:
|
||||
provider = object.__new__(AzureAISearchContextProvider)
|
||||
context = Mock(spec=SessionContext)
|
||||
context.input_messages = []
|
||||
|
||||
with patch("agent_framework_azure_ai_search._context_provider.mark_feature_used") as mark_feature_used:
|
||||
await provider.before_run(agent=Mock(), session=Mock(spec=AgentSession), context=context, state={})
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.AZURE_AI_SEARCH)
|
||||
|
||||
|
||||
def _make_mock_index(
|
||||
fields: list[SimpleNamespace] | None = None,
|
||||
profiles: list[SimpleNamespace] | None = None,
|
||||
|
||||
+4
@@ -29,12 +29,15 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._sessions import AgentSession
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from azure.ai.contentunderstanding import to_llm_input
|
||||
from azure.ai.contentunderstanding.aio import ContentUnderstandingClient
|
||||
from azure.ai.contentunderstanding.models import AnalysisInput, AnalysisResult
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework._agents import SupportsAgentRun
|
||||
|
||||
@@ -275,6 +278,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
|
||||
|
||||
This method is called automatically by the framework before each LLM invocation.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.AZURE_CONTENTUNDERSTANDING)
|
||||
documents: dict[str, DocumentEntry] = state.setdefault("documents", {})
|
||||
|
||||
# Per-session mutable state — isolated per session to prevent cross-session leakage.
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Azure Content Understanding-owned feature-usage indexes."""
|
||||
|
||||
AZURE_CONTENTUNDERSTANDING = 67
|
||||
@@ -7,7 +7,7 @@ import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from agent_framework import Content, Message, SessionContext
|
||||
from agent_framework._sessions import AgentSession
|
||||
@@ -18,6 +18,7 @@ from agent_framework_azure_contentunderstanding import (
|
||||
DocumentStatus,
|
||||
)
|
||||
from agent_framework_azure_contentunderstanding._detection import SUPPORTED_MEDIA_TYPES, derive_doc_key
|
||||
from agent_framework_azure_contentunderstanding._feature_usage import FeatureIndex
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -983,7 +984,12 @@ class TestErrorHandling:
|
||||
state: dict[str, Any] = {}
|
||||
session = AgentSession()
|
||||
|
||||
await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state)
|
||||
with patch(
|
||||
"agent_framework_azure_contentunderstanding._context_provider.mark_feature_used"
|
||||
) as mark_feature_used:
|
||||
await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state)
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.AZURE_CONTENTUNDERSTANDING)
|
||||
# Client should still be set
|
||||
assert provider._client is not None
|
||||
|
||||
|
||||
+7
@@ -17,6 +17,9 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, cast
|
||||
|
||||
from agent_framework import AgentSession, ContextProvider, Message, SessionContext
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
@@ -344,6 +347,8 @@ class CosmosMemoryContextProvider(ContextProvider):
|
||||
context: The invocation context to add memories to.
|
||||
state: Provider-scoped mutable state.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.AZURE_COSMOS_MEMORY)
|
||||
|
||||
# Extract query from input messages
|
||||
query_text = "\n".join(msg.text for msg in context.input_messages if msg.text and msg.text.strip())
|
||||
|
||||
@@ -424,6 +429,8 @@ class CosmosMemoryContextProvider(ContextProvider):
|
||||
context: The invocation context with response populated.
|
||||
state: Provider-scoped mutable state.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.AZURE_COSMOS_MEMORY)
|
||||
|
||||
# Get user_id and thread_id from provider-scoped state (falling back to the session id)
|
||||
user_id = self._resolve_user_id(state, session)
|
||||
thread_id = state.get("thread_id") or session.session_id or "default"
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Azure Cosmos DB memory-owned feature-usage indexes."""
|
||||
|
||||
AZURE_COSMOS_MEMORY = 82
|
||||
@@ -23,12 +23,29 @@ from agent_framework_azure_cosmos_memory._context_provider import (
|
||||
DEFAULT_CONTEXT_PROMPT,
|
||||
CosmosMemoryContextProvider,
|
||||
)
|
||||
from agent_framework_azure_cosmos_memory._feature_usage import FeatureIndex
|
||||
|
||||
# The provider methods accept an ``agent`` implementing ``SupportsAgentRun`` but never
|
||||
# use it in these tests, so a typed ``None`` stub keeps the call sites clean.
|
||||
_STUB_AGENT: Any = None
|
||||
|
||||
|
||||
async def test_before_run_marks_cosmos_memory_used_before_empty_return() -> None:
|
||||
provider = object.__new__(CosmosMemoryContextProvider)
|
||||
context = MagicMock(spec=SessionContext)
|
||||
context.input_messages = []
|
||||
|
||||
with patch("agent_framework_azure_cosmos_memory._context_provider.mark_feature_used") as mark_feature_used:
|
||||
await provider.before_run(
|
||||
agent=_STUB_AGENT,
|
||||
session=MagicMock(spec=AgentSession),
|
||||
context=context,
|
||||
state={},
|
||||
)
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.AZURE_COSMOS_MEMORY)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory_client() -> AsyncMock:
|
||||
"""Create a mock AsyncCosmosMemoryClient."""
|
||||
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import get_user_agent, mark_feature_used
|
||||
from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint
|
||||
from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from agent_framework.exceptions import WorkflowCheckpointException
|
||||
@@ -18,6 +18,8 @@ from azure.cosmos import PartitionKey
|
||||
from azure.cosmos.aio import ContainerProxy, CosmosClient
|
||||
from azure.cosmos.exceptions import CosmosResourceNotFoundError
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -214,6 +216,7 @@ class CosmosCheckpointStorage:
|
||||
Returns:
|
||||
The unique ID of the saved checkpoint.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.AZURE_COSMOS)
|
||||
await self._ensure_container_proxy()
|
||||
|
||||
checkpoint_dict = checkpoint.to_dict()
|
||||
@@ -242,6 +245,7 @@ class CosmosCheckpointStorage:
|
||||
WorkflowCheckpointException: If no checkpoint with the given ID exists,
|
||||
or if multiple checkpoints share the same ID across workflows.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.AZURE_COSMOS)
|
||||
await self._ensure_container_proxy()
|
||||
|
||||
query = "SELECT * FROM c WHERE c.checkpoint_id = @checkpoint_id"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Azure Cosmos DB-owned feature-usage indexes."""
|
||||
|
||||
AZURE_COSMOS = 66
|
||||
@@ -13,12 +13,14 @@ from typing import Any, ClassVar, TypedDict
|
||||
from agent_framework import Message
|
||||
from agent_framework._sessions import HistoryProvider
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import get_user_agent, mark_feature_used
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.cosmos import PartitionKey
|
||||
from azure.cosmos.aio import ContainerProxy, CosmosClient, DatabaseProxy
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -136,6 +138,7 @@ class CosmosHistoryProvider(HistoryProvider):
|
||||
**kwargs: Any,
|
||||
) -> list[Message]:
|
||||
"""Retrieve stored messages for this session from Azure Cosmos DB."""
|
||||
mark_feature_used(FeatureIndex.AZURE_COSMOS)
|
||||
await self._ensure_container_proxy()
|
||||
session_key = self._session_partition_key(session_id)
|
||||
|
||||
@@ -176,6 +179,7 @@ class CosmosHistoryProvider(HistoryProvider):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Persist messages for this session to Azure Cosmos DB."""
|
||||
mark_feature_used(FeatureIndex.AZURE_COSMOS)
|
||||
if not messages:
|
||||
return
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from azure.cosmos.aio import CosmosClient
|
||||
from azure.cosmos.exceptions import CosmosResourceNotFoundError
|
||||
|
||||
import agent_framework_azure_cosmos._history_provider as history_provider_module
|
||||
from agent_framework_azure_cosmos._feature_usage import FeatureIndex
|
||||
from agent_framework_azure_cosmos._history_provider import CosmosHistoryProvider
|
||||
|
||||
skip_if_cosmos_integration_tests_disabled = pytest.mark.skipif(
|
||||
@@ -44,6 +45,15 @@ def _to_async_iter(items: list[Any]) -> AsyncIterator[Any]:
|
||||
return _iterator()
|
||||
|
||||
|
||||
async def test_save_messages_marks_azure_cosmos_used_before_empty_return() -> None:
|
||||
provider = object.__new__(CosmosHistoryProvider)
|
||||
|
||||
with patch("agent_framework_azure_cosmos._history_provider.mark_feature_used") as mark_feature_used:
|
||||
await provider.save_messages(None, [])
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.AZURE_COSMOS)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_container() -> MagicMock:
|
||||
container = MagicMock()
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
from agent_framework import SupportsAgentRun, Workflow
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework_durabletask import (
|
||||
DEFAULT_MAX_POLL_RETRIES,
|
||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
@@ -56,6 +57,7 @@ from agent_framework_durabletask._workflows.serialization import strip_pickle_ma
|
||||
|
||||
from ._entities import create_agent_entity
|
||||
from ._errors import IncomingRequestError
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor
|
||||
from ._routes import build_workflow_respond_url, build_workflow_status_url, split_request_url
|
||||
from ._workflow import run_workflow_orchestrator
|
||||
@@ -292,6 +294,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
if self.enable_health_check:
|
||||
self._setup_health_route()
|
||||
|
||||
mark_feature_used(FeatureIndex.AZUREFUNCTIONS)
|
||||
logger.debug("[AgentFunctionApp] Initialization complete")
|
||||
|
||||
def _collect_workflows(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Azure Functions-owned feature-usage indexes."""
|
||||
|
||||
AZUREFUNCTIONS = 78
|
||||
@@ -31,7 +31,7 @@ from agent_framework import (
|
||||
validate_tool_mode,
|
||||
)
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import get_user_agent, mark_feature_used
|
||||
from agent_framework.exceptions import ChatClientInvalidResponseException
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from boto3.session import Session as Boto3Session
|
||||
@@ -40,6 +40,8 @@ from botocore.config import Config as BotoConfig
|
||||
from botocore.exceptions import ClientError
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
@@ -331,6 +333,7 @@ class BedrockChatClient(
|
||||
return Boto3Session(**session_kwargs)
|
||||
|
||||
def _invoke_converse(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
mark_feature_used(FeatureIndex.BEDROCK)
|
||||
try:
|
||||
response = self._bedrock_client.converse(**request)
|
||||
if not isinstance(response, Mapping):
|
||||
|
||||
@@ -19,12 +19,14 @@ from agent_framework import (
|
||||
UsageDetails,
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import get_user_agent, mark_feature_used
|
||||
from agent_framework.observability import EmbeddingTelemetryLayer
|
||||
from boto3.session import Session as Boto3Session
|
||||
from botocore.client import BaseClient
|
||||
from botocore.config import Config as BotoConfig
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
@@ -180,6 +182,7 @@ class RawBedrockEmbeddingClient(
|
||||
if not model:
|
||||
raise ValueError("model is required")
|
||||
|
||||
mark_feature_used(FeatureIndex.BEDROCK)
|
||||
embedding_results = await asyncio.gather(
|
||||
*(self._generate_embedding_for_text(opts, model, text) for text in values)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Amazon Bedrock-owned feature-usage indexes."""
|
||||
|
||||
BEDROCK = 58
|
||||
@@ -16,6 +16,7 @@ from botocore.client import BaseClient
|
||||
|
||||
from agent_framework_bedrock import BedrockChatClient
|
||||
from agent_framework_bedrock._chat_client import BedrockSettings
|
||||
from agent_framework_bedrock._feature_usage import FeatureIndex
|
||||
|
||||
|
||||
class _StubBedrockRuntime:
|
||||
@@ -67,8 +68,10 @@ async def test_get_response_invokes_bedrock_runtime() -> None:
|
||||
Message(role="user", contents=[Content.from_text(text="hello")]),
|
||||
]
|
||||
|
||||
response = await client.get_response(messages=messages, options={"max_tokens": 32})
|
||||
with patch("agent_framework_bedrock._chat_client.mark_feature_used") as mark_feature_used:
|
||||
response = await client.get_response(messages=messages, options={"max_tokens": 32})
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.BEDROCK)
|
||||
assert stub.calls, "Expected the runtime client to be called"
|
||||
payload = stub.calls[0]
|
||||
assert payload["modelId"] == "amazon.titan-text"
|
||||
|
||||
@@ -11,6 +11,7 @@ from agent_framework import (
|
||||
Content,
|
||||
Message,
|
||||
)
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from chatkit.types import (
|
||||
AssistantMessageItem,
|
||||
Attachment,
|
||||
@@ -30,6 +31,8 @@ from chatkit.types import (
|
||||
WorkflowItem,
|
||||
)
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -610,4 +613,5 @@ async def simple_to_agent_input(thread_items: Sequence[ThreadItem] | ThreadItem)
|
||||
# Convert multiple items
|
||||
messages = await simple_to_agent_input([user_message_item, assistant_message_item, task_item])
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.CHATKIT)
|
||||
return await _DEFAULT_CONVERTER.to_agent_input(thread_items)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""ChatKit-owned feature-usage indexes."""
|
||||
|
||||
CHATKIT = 73
|
||||
@@ -7,6 +7,7 @@ from collections.abc import AsyncIterable, AsyncIterator, Callable
|
||||
from datetime import datetime
|
||||
|
||||
from agent_framework import AgentResponseUpdate
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from chatkit.types import (
|
||||
AssistantMessageContent,
|
||||
AssistantMessageContentPartTextDelta,
|
||||
@@ -17,6 +18,8 @@ from chatkit.types import (
|
||||
ThreadStreamEvent,
|
||||
)
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
|
||||
async def stream_agent_response(
|
||||
response_stream: AsyncIterable[AgentResponseUpdate],
|
||||
@@ -44,6 +47,7 @@ async def stream_agent_response(
|
||||
ThreadStreamEvent: ChatKit events representing the agent's response,
|
||||
including incremental text deltas for streaming display.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.CHATKIT)
|
||||
# Use provided ID generator or create default one
|
||||
if generate_id is None:
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from agent_framework import (
|
||||
normalize_messages,
|
||||
normalize_tools,
|
||||
)
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework.exceptions import AgentException
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
from claude_agent_sdk import (
|
||||
@@ -42,6 +43,8 @@ from claude_agent_sdk import (
|
||||
)
|
||||
from claude_agent_sdk.types import StreamEvent, TextBlock
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
@@ -779,6 +782,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
session_id: str | None = None
|
||||
structured_output: Any = None
|
||||
|
||||
mark_feature_used(FeatureIndex.CLAUDE)
|
||||
await self._client.query(prompt)
|
||||
async for message in self._client.receive_response():
|
||||
if isinstance(message, StreamEvent):
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Claude Agent SDK-owned feature-usage indexes."""
|
||||
|
||||
CLAUDE = 62
|
||||
@@ -9,6 +9,7 @@ from agent_framework._settings import load_settings
|
||||
|
||||
from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings
|
||||
from agent_framework_claude._agent import TOOLS_MCP_SERVER_NAME
|
||||
from agent_framework_claude._feature_usage import FeatureIndex
|
||||
|
||||
# region Test ClaudeAgentSettings
|
||||
|
||||
@@ -231,9 +232,13 @@ class TestClaudeAgentRun:
|
||||
]
|
||||
mock_client = self._create_mock_client(messages)
|
||||
|
||||
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
|
||||
with (
|
||||
patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client),
|
||||
patch("agent_framework_claude._agent.mark_feature_used") as mark_feature_used,
|
||||
):
|
||||
agent = ClaudeAgent()
|
||||
response = await agent.run("Hello")
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.CLAUDE)
|
||||
assert response.text == "Hello!"
|
||||
|
||||
async def test_run_captures_session_id(self) -> None:
|
||||
|
||||
@@ -18,11 +18,13 @@ from agent_framework import (
|
||||
normalize_messages,
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework._types import AgentRunInputs
|
||||
from agent_framework.exceptions import AgentException
|
||||
from microsoft_agents.copilotstudio.client import AgentType, ConnectionSettings, CopilotClient, PowerPlatformCloud
|
||||
|
||||
from ._acquire_token import acquire_token
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
|
||||
class CopilotStudioSettings(TypedDict, total=False):
|
||||
@@ -255,6 +257,7 @@ class CopilotStudioAgent(BaseAgent):
|
||||
|
||||
question = "\n".join([message.text for message in input_messages])
|
||||
|
||||
mark_feature_used(FeatureIndex.COPILOTSTUDIO)
|
||||
activities = self.client.ask_question(question, service_session_id)
|
||||
response_messages: list[Message] = []
|
||||
response_id: str | None = None
|
||||
@@ -287,6 +290,7 @@ class CopilotStudioAgent(BaseAgent):
|
||||
|
||||
question = "\n".join([message.text for message in input_messages])
|
||||
|
||||
mark_feature_used(FeatureIndex.COPILOTSTUDIO)
|
||||
activities = self.client.ask_question(question, service_session_id)
|
||||
|
||||
async for message in self._process_activities(activities, streaming=True):
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Copilot Studio-owned feature-usage indexes."""
|
||||
|
||||
COPILOTSTUDIO = 63
|
||||
@@ -9,6 +9,7 @@ from agent_framework.exceptions import AgentException
|
||||
from microsoft_agents.copilotstudio.client import CopilotClient
|
||||
|
||||
from agent_framework_copilotstudio import CopilotStudioAgent
|
||||
from agent_framework_copilotstudio._feature_usage import FeatureIndex
|
||||
|
||||
|
||||
def create_async_generator(items: list[Any]) -> Any:
|
||||
@@ -136,8 +137,10 @@ class TestCopilotStudioAgent:
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
response = await agent.run("test message")
|
||||
with patch("agent_framework_copilotstudio._agent.mark_feature_used") as mark_feature_used:
|
||||
response = await agent.run("test message")
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.COPILOTSTUDIO)
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert len(response.messages) == 1
|
||||
content = response.messages[0].contents[0]
|
||||
|
||||
@@ -17,6 +17,7 @@ agent_framework/
|
||||
├── _sessions.py # AgentSession and context provider abstractions
|
||||
├── _skills.py # Agent Skills system (models, executors, provider)
|
||||
├── _mcp.py # Model Context Protocol support
|
||||
├── _telemetry.py # User-Agent identity and internal feature-usage mask
|
||||
├── _workflows/ # Workflow orchestration (sequential, concurrent, handoff, etc.)
|
||||
├── openai/ # Built-in OpenAI client
|
||||
├── azure/ # Lazy-loading entry point for Azure integrations
|
||||
|
||||
@@ -53,6 +53,17 @@ client = OpenAIChatClient(
|
||||
)
|
||||
```
|
||||
|
||||
### Telemetry controls
|
||||
|
||||
Agent Framework adds its package/version User-Agent to supported client
|
||||
requests. Approved Microsoft Foundry and Azure OpenAI request paths can also
|
||||
carry a documented feature-usage token.
|
||||
|
||||
- `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED=true` disables only the feature-usage
|
||||
token while retaining the package/version User-Agent.
|
||||
- `AGENT_FRAMEWORK_USER_AGENT_DISABLED=true` disables the entire Agent Framework
|
||||
User-Agent contribution, including the feature token.
|
||||
|
||||
See the following [getting started samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/01-get-started) for more information.
|
||||
|
||||
## 2. Create a Simple Agent
|
||||
|
||||
@@ -18,7 +18,7 @@ from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
try:
|
||||
_version = importlib.metadata.version(__name__)
|
||||
_version = importlib.metadata.version("agent-framework-core")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
_version = "0.0.0" # Fallback for development mode
|
||||
__version__: Final[str] = _version
|
||||
|
||||
@@ -37,6 +37,7 @@ from ._sessions import (
|
||||
SessionContext,
|
||||
is_local_history_conversation_id,
|
||||
)
|
||||
from ._telemetry import FeatureIndex, mark_feature_used
|
||||
from ._types import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
@@ -1773,6 +1774,7 @@ class Agent(
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
|
||||
"""Run the agent."""
|
||||
mark_feature_used(FeatureIndex.CORE_AGENT)
|
||||
super_run = cast(
|
||||
"Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]",
|
||||
super().run,
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import (
|
||||
)
|
||||
|
||||
from ._sessions import ContextProvider
|
||||
from ._telemetry import FeatureIndex, mark_feature_used
|
||||
from ._types import ChatResponse, Content, Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1539,6 +1540,7 @@ class CompactionProvider(ContextProvider):
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Compact messages already present in the context from earlier providers."""
|
||||
mark_feature_used(FeatureIndex.CORE_COMPACTION_PROVIDER)
|
||||
if self.before_strategy is None:
|
||||
return
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from .._compaction import CompactionProvider, ContextWindowCompactionStrategy
|
||||
from .._feature_stage import ExperimentalFeature, warn_experimental_feature
|
||||
from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider, MessageInjectionMiddleware
|
||||
from .._skills import SkillsProvider
|
||||
from .._telemetry import FeatureIndex, mark_feature_used
|
||||
from .._types import ChatOptions
|
||||
from ._background_agents import BackgroundAgentsProvider
|
||||
from ._file_access import AgentFileStore, FileAccessProvider, FileSystemAgentFileStore
|
||||
@@ -674,5 +675,6 @@ def create_harness_agent(
|
||||
|
||||
# Set the telemetry provider name after construction.
|
||||
agent.otel_provider_name = otel_provider_name or HARNESS_AGENT_PROVIDER_NAME
|
||||
mark_feature_used(FeatureIndex.CORE_HARNESS_AGENT)
|
||||
|
||||
return agent
|
||||
|
||||
@@ -19,6 +19,7 @@ from .._agents import SupportsAgentRun
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._telemetry import FeatureIndex, mark_feature_used
|
||||
from .._tools import tool
|
||||
from .._types import AgentResponse, Message
|
||||
|
||||
@@ -320,6 +321,7 @@ class BackgroundAgentsProvider(ContextProvider):
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject background agent tools and instructions before the model runs."""
|
||||
mark_feature_used(FeatureIndex.CORE_BACKGROUND_AGENTS_PROVIDER)
|
||||
del agent, state
|
||||
|
||||
provider_state = _get_provider_state(session, source_id=self.source_id)
|
||||
|
||||
@@ -38,6 +38,7 @@ from pydantic import BaseModel, Field
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._telemetry import FeatureIndex, mark_feature_used
|
||||
from .._tools import ApprovalMode, tool
|
||||
from .._types import Content
|
||||
|
||||
@@ -1454,6 +1455,7 @@ class FileAccessProvider(ContextProvider):
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject file-access tools and instructions before the model runs."""
|
||||
mark_feature_used(FeatureIndex.CORE_FILE_ACCESS_PROVIDER)
|
||||
readonly_approval: ApprovalMode = "never_require" if self.disable_readonly_tool_approval else "always_require"
|
||||
write_approval: ApprovalMode = "never_require" if self.disable_write_tool_approval else "always_require"
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .._clients import SupportsChatGetResponse
|
||||
from .._compaction import group_messages
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._sessions import AgentSession, FileHistoryProvider, HistoryProvider, JsonDumps, JsonLoads, SessionContext
|
||||
from .._telemetry import FeatureIndex, mark_feature_used
|
||||
from .._tools import tool
|
||||
from .._types import ChatResponse, Message
|
||||
from ..exceptions import ChatClientException
|
||||
@@ -1170,6 +1171,7 @@ class MemoryContextProvider(HistoryProvider):
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject ``MEMORY.md`` and selected topic files before the model runs."""
|
||||
mark_feature_used(FeatureIndex.CORE_MEMORY_PROVIDER)
|
||||
state.clear()
|
||||
state.update(self.store.export_provider_state(session))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from collections.abc import Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._telemetry import FeatureIndex, mark_feature_used
|
||||
from .._tools import tool
|
||||
from .._types import Message
|
||||
|
||||
@@ -272,6 +273,7 @@ class AgentModeProvider(ContextProvider):
|
||||
context: The session context to receive instructions and tools.
|
||||
state: Per-provider invocation state.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.CORE_AGENT_MODE_PROVIDER)
|
||||
del agent, state
|
||||
current_mode = get_agent_mode(
|
||||
session,
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing_extensions import NotRequired, TypedDict
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._telemetry import FeatureIndex, mark_feature_used
|
||||
from .._tools import tool
|
||||
from .._types import Message
|
||||
|
||||
@@ -498,6 +499,7 @@ class TodoProvider(ContextProvider):
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject todo tools and instructions before the model runs."""
|
||||
mark_feature_used(FeatureIndex.CORE_TODO_PROVIDER)
|
||||
del agent, state
|
||||
|
||||
@tool(name="todos_add", approval_mode="never_require")
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Literal, cast
|
||||
from .._middleware import AgentContext, AgentMiddleware
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession
|
||||
from .._telemetry import FeatureIndex, mark_feature_used
|
||||
from .._types import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
@@ -379,6 +380,7 @@ class ToolApprovalMiddleware(AgentMiddleware):
|
||||
|
||||
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
"""Process one agent invocation."""
|
||||
mark_feature_used(FeatureIndex.CORE_TOOL_APPROVAL)
|
||||
if context.session is None:
|
||||
raise RuntimeError("ToolApprovalMiddleware requires an AgentSession.")
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from ._feature_stage import (
|
||||
_warn_on_feature_use, # pyright: ignore[reportPrivateUsage]
|
||||
experimental,
|
||||
)
|
||||
from ._telemetry import FeatureIndex, mark_feature_used
|
||||
from ._tools import FunctionTool
|
||||
from ._types import (
|
||||
ChatOptions,
|
||||
@@ -1268,6 +1269,7 @@ class MCPTool:
|
||||
await self._run_on_lifecycle_owner("connect", reset=True, load_configured=False)
|
||||
|
||||
async def connect(self, *, reset: bool = False) -> None:
|
||||
mark_feature_used(FeatureIndex.CORE_MCP)
|
||||
if self._is_lifecycle_owner_task():
|
||||
await self._connect_on_owner(reset=reset)
|
||||
return
|
||||
|
||||
@@ -30,6 +30,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypeGuard, cast
|
||||
|
||||
from ._feature_stage import ExperimentalFeature, experimental
|
||||
from ._middleware import ChatContext, ChatMiddleware
|
||||
from ._telemetry import FeatureIndex, mark_feature_used
|
||||
from ._types import (
|
||||
AgentResponse,
|
||||
AgentRunInputs,
|
||||
@@ -1168,6 +1169,7 @@ class InMemoryHistoryProvider(HistoryProvider):
|
||||
self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any
|
||||
) -> list[Message]:
|
||||
"""Retrieve messages from session state."""
|
||||
mark_feature_used(FeatureIndex.CORE_IN_MEMORY_HISTORY_PROVIDER)
|
||||
if state is None:
|
||||
return []
|
||||
messages = list(state.get("messages", []))
|
||||
@@ -1184,6 +1186,7 @@ class InMemoryHistoryProvider(HistoryProvider):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Persist messages to session state."""
|
||||
mark_feature_used(FeatureIndex.CORE_IN_MEMORY_HISTORY_PROVIDER)
|
||||
if state is None:
|
||||
return
|
||||
existing = state.get("messages", [])
|
||||
@@ -1303,6 +1306,7 @@ class FileHistoryProvider(HistoryProvider):
|
||||
**kwargs: Any,
|
||||
) -> list[Message]:
|
||||
"""Retrieve messages from the session's JSON Lines file."""
|
||||
mark_feature_used(FeatureIndex.CORE_FILE_HISTORY_PROVIDER)
|
||||
del state, kwargs
|
||||
file_path = self._session_file_path(session_id)
|
||||
async_lock = self._session_async_write_lock(file_path)
|
||||
@@ -1354,6 +1358,7 @@ class FileHistoryProvider(HistoryProvider):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Append messages to the session's JSON Lines file."""
|
||||
mark_feature_used(FeatureIndex.CORE_FILE_HISTORY_PROVIDER)
|
||||
del state, kwargs
|
||||
if not messages:
|
||||
return
|
||||
|
||||
@@ -62,6 +62,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, TypeAlias, Typ
|
||||
|
||||
from ._feature_stage import ExperimentalFeature, experimental
|
||||
from ._sessions import ContextProvider
|
||||
from ._telemetry import FeatureIndex, mark_feature_used
|
||||
from ._tools import ApprovalMode, FunctionTool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -2407,6 +2408,7 @@ class SkillsProvider(ContextProvider):
|
||||
context: Session context to extend with instructions and tools.
|
||||
state: Mutable per-run state dictionary (unused by this provider).
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.CORE_SKILLS_PROVIDER)
|
||||
source_context = SkillsSourceContext(agent=agent, session=session)
|
||||
skills, instructions, tools = await self._create_context(source_context)
|
||||
|
||||
@@ -2886,6 +2888,7 @@ class FileSkillsSource(SkillsSource):
|
||||
Returns:
|
||||
A list of discovered file-based skills.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.CORE_FILE_SKILLS_SOURCE)
|
||||
skills: dict[str, FileSkill] = {}
|
||||
|
||||
discovered = FileSkillsSource._discover_skill_directories(self._skill_paths)
|
||||
@@ -3591,6 +3594,7 @@ class InMemorySkillsSource(SkillsSource):
|
||||
Returns:
|
||||
A list of :class:`Skill` instances.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.CORE_IN_MEMORY_SKILLS_SOURCE)
|
||||
return self._skills
|
||||
|
||||
|
||||
@@ -4374,6 +4378,7 @@ class MCPSkillsSource(SkillsSource):
|
||||
Returns:
|
||||
A list of discovered :class:`MCPSkill` instances.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.CORE_MCP_SKILLS_SOURCE)
|
||||
index = await self._try_read_index()
|
||||
if index is None:
|
||||
return []
|
||||
|
||||
@@ -5,6 +5,9 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from enum import IntEnum
|
||||
from typing import Any, Final
|
||||
|
||||
from . import __version__ as version_info
|
||||
@@ -15,6 +18,8 @@ logger = logging.getLogger("agent_framework")
|
||||
# Note that if this environment variable does not exist, user agent telemetry is enabled.
|
||||
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR = "AGENT_FRAMEWORK_USER_AGENT_DISABLED"
|
||||
IS_TELEMETRY_ENABLED = os.environ.get(USER_AGENT_TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"]
|
||||
FEATURE_MASK_DISABLED_ENV_VAR = "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED"
|
||||
FEATURE_REGISTRY_VERSION = 1
|
||||
|
||||
APP_INFO = (
|
||||
{
|
||||
@@ -27,6 +32,29 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
|
||||
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
|
||||
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}"
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Core-owned indexes in the Python feature-usage registry."""
|
||||
|
||||
CORE_AGENT = 0
|
||||
CORE_HARNESS_AGENT = 1
|
||||
CORE_WORKFLOW = 2
|
||||
CORE_MCP = 3
|
||||
CORE_TOOL_APPROVAL = 4
|
||||
CORE_MEMORY_PROVIDER = 5
|
||||
CORE_SKILLS_PROVIDER = 6
|
||||
CORE_FILE_ACCESS_PROVIDER = 7
|
||||
CORE_COMPACTION_PROVIDER = 8
|
||||
CORE_TODO_PROVIDER = 9
|
||||
CORE_AGENT_MODE_PROVIDER = 10
|
||||
CORE_BACKGROUND_AGENTS_PROVIDER = 11
|
||||
CORE_IN_MEMORY_HISTORY_PROVIDER = 12
|
||||
CORE_FILE_HISTORY_PROVIDER = 13
|
||||
CORE_FILE_SKILLS_SOURCE = 14
|
||||
CORE_IN_MEMORY_SKILLS_SOURCE = 15
|
||||
CORE_MCP_SKILLS_SOURCE = 16
|
||||
|
||||
|
||||
# This environment variable is reserved by the Foundry hosting environment to
|
||||
# indicate that the agent is running in a hosted environment.
|
||||
_FOUNDRY_HOSTING_ENV_VAR = "FOUNDRY_HOSTING_ENVIRONMENT"
|
||||
@@ -35,6 +63,9 @@ _HOSTED_USER_AGENT_PREFIX = "foundry-hosting"
|
||||
|
||||
_user_agent_prefixes: set[str] = set()
|
||||
_hosted_env_detected: bool = False
|
||||
_feature_mask = 0
|
||||
_feature_mask_lock = threading.Lock()
|
||||
_feature_comment_pattern = re.compile(r"(?:^|\s+)\(feat=v\d+\.[0-9a-fA-F]+\)")
|
||||
|
||||
|
||||
def _add_user_agent_prefix(prefix: str) -> None:
|
||||
@@ -96,6 +127,53 @@ def get_user_agent() -> str:
|
||||
return f"{'/'.join(sorted(_user_agent_prefixes))}/{AGENT_FRAMEWORK_USER_AGENT}"
|
||||
|
||||
|
||||
def _feature_mask_enabled() -> bool:
|
||||
"""Return whether feature-usage marking and emission are enabled."""
|
||||
return IS_TELEMETRY_ENABLED and os.environ.get(FEATURE_MASK_DISABLED_ENV_VAR, "false").lower() not in ("true", "1")
|
||||
|
||||
|
||||
def mark_feature_used(index: IntEnum | int) -> None:
|
||||
"""Mark a feature as used in the process-global feature mask."""
|
||||
if not _feature_mask_enabled():
|
||||
return
|
||||
|
||||
feature_index = int(index)
|
||||
if not 0 <= feature_index < 128:
|
||||
raise ValueError(f"Feature index must be in range 0..127, got {feature_index}")
|
||||
|
||||
global _feature_mask
|
||||
with _feature_mask_lock:
|
||||
_feature_mask |= 1 << feature_index
|
||||
|
||||
|
||||
def get_feature_token() -> str | None:
|
||||
"""Return the current versioned feature token, or None when empty or disabled."""
|
||||
if not _feature_mask_enabled():
|
||||
return None
|
||||
|
||||
with _feature_mask_lock:
|
||||
feature_mask = _feature_mask
|
||||
if feature_mask == 0:
|
||||
return None
|
||||
return f"v{FEATURE_REGISTRY_VERSION}.{feature_mask:x}"
|
||||
|
||||
|
||||
def apply_feature_token(user_agent: str) -> str:
|
||||
"""Append or refresh the live feature token in a User-Agent value."""
|
||||
base_user_agent = remove_feature_token(user_agent)
|
||||
token = get_feature_token()
|
||||
if token is None:
|
||||
return base_user_agent
|
||||
if not base_user_agent:
|
||||
return f"(feat={token})"
|
||||
return f"{base_user_agent} (feat={token})"
|
||||
|
||||
|
||||
def remove_feature_token(user_agent: str) -> str:
|
||||
"""Remove the Agent Framework feature token from a User-Agent value."""
|
||||
return _feature_comment_pattern.sub("", user_agent).strip()
|
||||
|
||||
|
||||
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Prepend "agent-framework" to the User-Agent in the headers.
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import logging
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, Literal
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
from .._agents import SupportsAgentRun
|
||||
from .._telemetry import FeatureIndex, mark_feature_used
|
||||
from ..observability import OtelAttr, capture_exception, create_workflow_span
|
||||
from ._agent_executor import AgentExecutor
|
||||
from ._agent_utils import resolve_agent_id
|
||||
@@ -85,6 +86,8 @@ class WorkflowBuilder:
|
||||
print(events.get_outputs()) # ['OLLEH']
|
||||
"""
|
||||
|
||||
_FEATURE_USAGE_INDEX: ClassVar[FeatureIndex | None] = FeatureIndex.CORE_WORKFLOW
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
@@ -800,6 +803,8 @@ class WorkflowBuilder:
|
||||
events = await workflow.run("hello")
|
||||
print(events.get_outputs()) # outputs from planner and answerer
|
||||
"""
|
||||
if self._FEATURE_USAGE_INDEX is not None:
|
||||
mark_feature_used(self._FEATURE_USAGE_INDEX)
|
||||
# Create workflow build span that includes validation and workflow creation
|
||||
with create_workflow_span(OtelAttr.WORKFLOW_BUILD_SPAN) as span:
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import ast
|
||||
import concurrent.futures
|
||||
import importlib.metadata
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import agent_framework._telemetry as _telemetry_mod
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
@@ -13,8 +20,15 @@ from agent_framework import (
|
||||
from agent_framework._telemetry import (
|
||||
_FOUNDRY_HOSTING_ENV_VAR,
|
||||
_HOSTED_USER_AGENT_PREFIX,
|
||||
FEATURE_MASK_DISABLED_ENV_VAR,
|
||||
FEATURE_REGISTRY_VERSION,
|
||||
FeatureIndex,
|
||||
_add_user_agent_prefix,
|
||||
_detect_hosted_environment,
|
||||
apply_feature_token,
|
||||
get_feature_token,
|
||||
mark_feature_used,
|
||||
remove_feature_token,
|
||||
)
|
||||
|
||||
# region Test constants
|
||||
@@ -35,6 +49,209 @@ def test_agent_framework_user_agent_format():
|
||||
assert AGENT_FRAMEWORK_USER_AGENT.startswith("agent-framework-python/")
|
||||
|
||||
|
||||
def test_agent_framework_user_agent_uses_core_distribution_version() -> None:
|
||||
core_version = importlib.metadata.version("agent-framework-core")
|
||||
|
||||
assert f"agent-framework-python/{core_version}" == AGENT_FRAMEWORK_USER_AGENT
|
||||
|
||||
|
||||
def _reset_feature_mask() -> None:
|
||||
with _telemetry_mod._feature_mask_lock:
|
||||
_telemetry_mod._feature_mask = 0
|
||||
|
||||
|
||||
def test_feature_mask_disabled_env_var() -> None:
|
||||
assert FEATURE_MASK_DISABLED_ENV_VAR == "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED"
|
||||
|
||||
|
||||
def test_feature_registry_version() -> None:
|
||||
assert FEATURE_REGISTRY_VERSION == 1
|
||||
|
||||
|
||||
def test_mark_feature_used_accumulates_and_deduplicates() -> None:
|
||||
_reset_feature_mask()
|
||||
with (
|
||||
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
|
||||
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
|
||||
):
|
||||
mark_feature_used(FeatureIndex.CORE_AGENT)
|
||||
mark_feature_used(FeatureIndex.CORE_AGENT)
|
||||
mark_feature_used(FeatureIndex.CORE_WORKFLOW)
|
||||
|
||||
assert get_feature_token() == "v1.5"
|
||||
|
||||
|
||||
def test_mark_feature_used_supports_bit_127() -> None:
|
||||
_reset_feature_mask()
|
||||
with (
|
||||
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
|
||||
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
|
||||
):
|
||||
mark_feature_used(127)
|
||||
|
||||
assert get_feature_token() == f"v1.{1 << 127:x}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bit", [-1, 128])
|
||||
def test_mark_feature_used_rejects_out_of_range_bit(bit: int) -> None:
|
||||
_reset_feature_mask()
|
||||
with (
|
||||
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
|
||||
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
|
||||
pytest.raises(ValueError, match="Feature index must be in range 0..127"),
|
||||
):
|
||||
mark_feature_used(bit)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("disabled_value", ["true", "TRUE", "1"])
|
||||
def test_feature_mask_env_var_disables_marking(disabled_value: str) -> None:
|
||||
_reset_feature_mask()
|
||||
with (
|
||||
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
|
||||
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: disabled_value}),
|
||||
):
|
||||
mark_feature_used(FeatureIndex.CORE_AGENT)
|
||||
|
||||
assert get_feature_token() is None
|
||||
|
||||
|
||||
def test_user_agent_env_var_disables_feature_mask() -> None:
|
||||
_reset_feature_mask()
|
||||
with (
|
||||
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", False),
|
||||
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
|
||||
):
|
||||
mark_feature_used(FeatureIndex.CORE_AGENT)
|
||||
|
||||
assert get_feature_token() is None
|
||||
|
||||
|
||||
def test_apply_feature_token_adds_and_refreshes_live_mask() -> None:
|
||||
_reset_feature_mask()
|
||||
with (
|
||||
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
|
||||
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
|
||||
):
|
||||
mark_feature_used(FeatureIndex.CORE_AGENT)
|
||||
user_agent = apply_feature_token("foundry-hosting/agent-framework-python/1.0")
|
||||
assert user_agent == "foundry-hosting/agent-framework-python/1.0 (feat=v1.1)"
|
||||
|
||||
mark_feature_used(FeatureIndex.CORE_WORKFLOW)
|
||||
assert apply_feature_token(user_agent) == "foundry-hosting/agent-framework-python/1.0 (feat=v1.5)"
|
||||
|
||||
|
||||
def test_apply_feature_token_preserves_unrelated_comments() -> None:
|
||||
_reset_feature_mask()
|
||||
with (
|
||||
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
|
||||
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
|
||||
):
|
||||
mark_feature_used(FeatureIndex.CORE_AGENT)
|
||||
|
||||
assert apply_feature_token("agent-framework-python/1.0 (custom=value)") == (
|
||||
"agent-framework-python/1.0 (custom=value) (feat=v1.1)"
|
||||
)
|
||||
|
||||
|
||||
def test_remove_feature_token_strips_standalone_token() -> None:
|
||||
assert remove_feature_token("(feat=v1.1)") == ""
|
||||
|
||||
|
||||
def test_apply_feature_token_removes_stale_token_when_disabled() -> None:
|
||||
_reset_feature_mask()
|
||||
with (
|
||||
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
|
||||
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "true"}),
|
||||
):
|
||||
assert apply_feature_token("agent-framework-python/1.0 (feat=v1.5)") == "agent-framework-python/1.0"
|
||||
assert apply_feature_token("(feat=v1.5)") == ""
|
||||
|
||||
|
||||
def test_mark_feature_used_is_thread_safe() -> None:
|
||||
_reset_feature_mask()
|
||||
with (
|
||||
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
|
||||
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
|
||||
concurrent.futures.ThreadPoolExecutor() as executor,
|
||||
):
|
||||
list(executor.map(mark_feature_used, range(128)))
|
||||
|
||||
assert get_feature_token() == f"v1.{(1 << 128) - 1:x}"
|
||||
|
||||
|
||||
def test_declared_feature_indexes_match_registry() -> None:
|
||||
registry_path = next(
|
||||
(
|
||||
parent / "docs" / "specs" / "feature-usage-bit-registry.md"
|
||||
for parent in Path(__file__).resolve().parents
|
||||
if (parent / "docs" / "specs" / "feature-usage-bit-registry.md").exists()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if registry_path is None:
|
||||
pytest.skip("Feature-usage registry is not available outside a repository checkout.")
|
||||
|
||||
repository_root = registry_path.parents[2]
|
||||
registry_text = registry_path.read_text(encoding="utf-8")
|
||||
python_table = registry_text.split("## Index table — Python", 1)[1].split("## Index table — .NET", 1)[0]
|
||||
registry_rows = re.findall(r"^\| (\d+) \| `([^`]+)` \|", python_table, re.MULTILINE)
|
||||
registry_pairs = {(int(index), identifier.upper().replace(".", "_")) for index, identifier in registry_rows}
|
||||
assert len(registry_pairs) == len(registry_rows), "Python v1 registry contains duplicate (index, id) rows."
|
||||
assert all(0 <= index < 128 for index, _ in registry_pairs)
|
||||
|
||||
declaration_files = [
|
||||
repository_root / "python" / "packages" / "core" / "agent_framework" / "_telemetry.py",
|
||||
*repository_root.glob("python/packages/**/_feature_usage.py"),
|
||||
]
|
||||
declarations_by_index: dict[int, str] = {}
|
||||
declaration_pairs: set[tuple[int, str]] = set()
|
||||
declaration_owners: dict[tuple[int, str], tuple[Path, Path]] = {}
|
||||
for declaration_file in declaration_files:
|
||||
tree = ast.parse(declaration_file.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ClassDef) or node.name != "FeatureIndex":
|
||||
continue
|
||||
for member in node.body:
|
||||
if not isinstance(member, ast.Assign) or len(member.targets) != 1:
|
||||
continue
|
||||
target = member.targets[0]
|
||||
if not isinstance(target, ast.Name) or not isinstance(member.value, ast.Constant):
|
||||
continue
|
||||
index = member.value.value
|
||||
if not isinstance(index, int):
|
||||
continue
|
||||
declaration = f"{declaration_file.relative_to(repository_root)}:{target.id}"
|
||||
assert 0 <= index < 128, f"Feature index {index} is out of range in {declaration}."
|
||||
assert index not in declarations_by_index, (
|
||||
f"Feature index {index} overlaps between {declarations_by_index[index]} and {declaration}."
|
||||
)
|
||||
declarations_by_index[index] = declaration
|
||||
pair = (index, target.id)
|
||||
declaration_pairs.add(pair)
|
||||
package_root = repository_root.joinpath(*declaration_file.relative_to(repository_root).parts[:3])
|
||||
declaration_owners[pair] = (package_root, declaration_file)
|
||||
|
||||
assert declaration_pairs == registry_pairs
|
||||
|
||||
for pair, (package_root, declaration_file) in declaration_owners.items():
|
||||
_, member_name = pair
|
||||
referenced = False
|
||||
for source_file in package_root.rglob("*.py"):
|
||||
if source_file == declaration_file or "tests" in source_file.parts:
|
||||
continue
|
||||
source_tree = ast.parse(source_file.read_text(encoding="utf-8"))
|
||||
if any(
|
||||
isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id == "FeatureIndex"
|
||||
and node.attr == member_name
|
||||
for node in ast.walk(source_tree)
|
||||
):
|
||||
referenced = True
|
||||
break
|
||||
assert referenced, f"Feature index {pair} is declared but never referenced by its owning package."
|
||||
|
||||
|
||||
def test_app_info_when_telemetry_enabled():
|
||||
"""Test that APP_INFO is set when telemetry is enabled."""
|
||||
with patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True):
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Declarative-owned feature-usage indexes."""
|
||||
|
||||
DECLARATIVE_AGENT = 75
|
||||
DECLARATIVE_WORKFLOW = 76
|
||||
@@ -19,9 +19,11 @@ from agent_framework._feature_stage import (
|
||||
ExperimentalFeature,
|
||||
experimental,
|
||||
)
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework.exceptions import AgentException
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._models import (
|
||||
AnonymousConnection,
|
||||
ApiKeyConnection,
|
||||
@@ -471,13 +473,15 @@ class AgentFactory:
|
||||
if output_schema := prompt_agent.outputSchema:
|
||||
chat_options["response_format"] = output_schema.to_json_schema()
|
||||
# Step 3: Create the agent instance
|
||||
return Agent(
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name=prompt_agent.name,
|
||||
description=prompt_agent.description,
|
||||
instructions=prompt_agent.instructions,
|
||||
default_options=chat_options, # type: ignore[arg-type]
|
||||
)
|
||||
mark_feature_used(FeatureIndex.DECLARATIVE_AGENT)
|
||||
return agent
|
||||
|
||||
async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent:
|
||||
"""Async version: Create a Agent from a YAML file path.
|
||||
@@ -582,13 +586,15 @@ class AgentFactory:
|
||||
chat_options["tools"] = tools
|
||||
if output_schema := prompt_agent.outputSchema:
|
||||
chat_options["response_format"] = output_schema.to_json_schema()
|
||||
return Agent(
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name=prompt_agent.name,
|
||||
description=prompt_agent.description,
|
||||
instructions=prompt_agent.instructions,
|
||||
default_options=chat_options, # type: ignore[arg-type]
|
||||
)
|
||||
mark_feature_used(FeatureIndex.DECLARATIVE_AGENT)
|
||||
return agent
|
||||
|
||||
async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> Agent:
|
||||
"""Create an Agent through a provider object that exposes ``create_agent``.
|
||||
|
||||
@@ -24,7 +24,9 @@ from agent_framework import (
|
||||
SupportsAgentRun,
|
||||
Workflow,
|
||||
)
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
|
||||
from .._feature_usage import FeatureIndex
|
||||
from .._loader import AgentFactory
|
||||
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
|
||||
from ._declarative_builder import DeclarativeWorkflowBuilder
|
||||
@@ -471,6 +473,7 @@ class WorkflowFactory:
|
||||
len(graph_builder._executors), # type: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
mark_feature_used(FeatureIndex.DECLARATIVE_WORKFLOW)
|
||||
return workflow
|
||||
|
||||
def _normalize_workflow_def(self, workflow_def: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -492,6 +492,38 @@ class TestAgentFactoryCreateFromDict:
|
||||
|
||||
assert agent is not None
|
||||
|
||||
def test_create_agent_from_dict_marks_declarative_agent_used(self):
|
||||
"""Test that successful declarative agent creation marks feature usage."""
|
||||
from agent_framework_declarative import AgentFactory
|
||||
from agent_framework_declarative._feature_usage import FeatureIndex
|
||||
|
||||
factory = AgentFactory(client=MagicMock())
|
||||
|
||||
with patch("agent_framework_declarative._loader.mark_feature_used") as mark_feature_used:
|
||||
factory.create_agent_from_dict({
|
||||
"kind": "Prompt",
|
||||
"name": "TestAgent",
|
||||
"instructions": "You are a helpful assistant.",
|
||||
})
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.DECLARATIVE_AGENT)
|
||||
|
||||
async def test_create_agent_from_dict_async_marks_declarative_agent_used(self):
|
||||
"""Test that successful async declarative agent creation marks feature usage."""
|
||||
from agent_framework_declarative import AgentFactory
|
||||
from agent_framework_declarative._feature_usage import FeatureIndex
|
||||
|
||||
factory = AgentFactory(client=MagicMock())
|
||||
|
||||
with patch("agent_framework_declarative._loader.mark_feature_used") as mark_feature_used:
|
||||
await factory.create_agent_from_dict_async({
|
||||
"kind": "Prompt",
|
||||
"name": "TestAgent",
|
||||
"instructions": "You are a helpful assistant.",
|
||||
})
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.DECLARATIVE_AGENT)
|
||||
|
||||
def test_create_agent_from_dict_matches_yaml(self):
|
||||
"""Test that create_agent_from_dict produces same result as create_agent_from_yaml."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
"""Unit tests for WorkflowFactory."""
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_declarative._feature_usage import FeatureIndex
|
||||
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
|
||||
from agent_framework_declarative._workflows._factory import WorkflowFactory
|
||||
|
||||
@@ -66,6 +68,24 @@ actions:
|
||||
assert workflow is not None
|
||||
assert workflow.name == "minimal-workflow"
|
||||
|
||||
def test_valid_workflow_marks_declarative_workflow_used(self):
|
||||
"""Test that successful declarative workflow creation marks feature usage."""
|
||||
factory = WorkflowFactory()
|
||||
|
||||
with patch("agent_framework_declarative._workflows._factory.mark_feature_used") as mark_feature_used:
|
||||
factory.create_workflow_from_definition({
|
||||
"name": "minimal-workflow",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "SetValue",
|
||||
"path": "Local.result",
|
||||
"value": "done",
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.DECLARATIVE_WORKFLOW)
|
||||
|
||||
|
||||
@_requires_powerfx
|
||||
class TestWorkflowFactoryExecution:
|
||||
|
||||
@@ -8,7 +8,10 @@ import webbrowser
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
|
||||
from ._conversations import CheckpointConversationManager
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._server import DevServer
|
||||
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
|
||||
from .models._discovery_models import DiscoveryResponse, EntityInfo, EnvVarRequirement
|
||||
@@ -196,6 +199,7 @@ def serve(
|
||||
threading.Thread(target=open_browser, daemon=True).start()
|
||||
|
||||
logger.info(f"Starting Agent Framework DevUI on {host}:{port}")
|
||||
mark_feature_used(FeatureIndex.DEVUI)
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""DevUI-owned feature-usage indexes."""
|
||||
|
||||
DEVUI = 74
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Durable Task-owned feature-usage indexes."""
|
||||
|
||||
DURABLETASK = 77
|
||||
@@ -13,9 +13,11 @@ from abc import ABC, abstractmethod
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
|
||||
from agent_framework import AgentSession, ServiceSessionId, SupportsAgentRun, normalize_messages
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework._types import AgentRunInputs
|
||||
|
||||
from ._executors import DurableAgentExecutor
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._models import DurableAgentSession
|
||||
|
||||
# TypeVar for the task type returned by executors
|
||||
@@ -127,6 +129,7 @@ class DurableAIAgent(SupportsAgentRun, Generic[TaskT]):
|
||||
options=options,
|
||||
)
|
||||
|
||||
mark_feature_used(FeatureIndex.DURABLETASK)
|
||||
return self._executor.run_durable_agent(
|
||||
agent_name=self.name,
|
||||
run_request=run_request,
|
||||
|
||||
@@ -13,12 +13,14 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import SupportsAgentRun, Workflow
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from durabletask.task import ActivityContext, OrchestrationContext
|
||||
from durabletask.worker import TaskHubGrpcWorker
|
||||
|
||||
from ._async_bridge import run_agent_coroutine
|
||||
from ._callbacks import AgentResponseCallbackProtocol
|
||||
from ._entities import AgentEntity, DurableTaskEntityStateProvider
|
||||
from ._feature_usage import FeatureIndex
|
||||
from ._workflows.activity import execute_workflow_activity
|
||||
from ._workflows.dt_context import DurableTaskWorkflowContext
|
||||
from ._workflows.naming import (
|
||||
@@ -157,6 +159,7 @@ class DurableAIAgentWorker:
|
||||
The worker will block until stopped.
|
||||
"""
|
||||
logger.info("[DurableAIAgentWorker] Starting worker with %d registered agents", len(self._registered_agents))
|
||||
mark_feature_used(FeatureIndex.DURABLETASK)
|
||||
self._worker.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
|
||||
@@ -29,7 +29,7 @@ from agent_framework import (
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import IS_TELEMETRY_ENABLED, get_user_agent
|
||||
from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer
|
||||
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
@@ -38,6 +38,11 @@ from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
|
||||
|
||||
from ._feature_usage import (
|
||||
FeatureIndex,
|
||||
create_feature_usage_policy,
|
||||
create_foundry_feature_usage_http_client,
|
||||
)
|
||||
from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
@@ -174,6 +179,7 @@ class RawFoundryAgentChatClient(
|
||||
"""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry"
|
||||
_FEATURE_USAGE_INDEX: ClassVar[int | None] = FeatureIndex.FOUNDRY_AGENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -251,8 +257,10 @@ class RawFoundryAgentChatClient(
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": resolved_endpoint,
|
||||
"credential": credential,
|
||||
"user_agent": get_user_agent(),
|
||||
"per_retry_policies": [create_feature_usage_policy()],
|
||||
}
|
||||
if IS_TELEMETRY_ENABLED:
|
||||
project_client_kwargs["user_agent"] = get_user_agent()
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
self.project_client = AIProjectClient(**project_client_kwargs)
|
||||
@@ -261,6 +269,8 @@ class RawFoundryAgentChatClient(
|
||||
openai_client_kwargs: dict[str, Any] = {}
|
||||
if default_headers:
|
||||
openai_client_kwargs["default_headers"] = dict(default_headers)
|
||||
if self._should_close_client:
|
||||
openai_client_kwargs["http_client"] = create_foundry_feature_usage_http_client()
|
||||
if allow_preview:
|
||||
openai_client_kwargs["agent_name"] = self.agent_name
|
||||
openai_client = self.project_client.get_openai_client(**openai_client_kwargs)
|
||||
@@ -799,7 +809,7 @@ class RawFoundryAgent(
|
||||
Foundry conversation ID.
|
||||
"""
|
||||
client = cast(RawFoundryAgentChatClient, self.client)
|
||||
conversation = await client.project_client.get_openai_client().conversations.create()
|
||||
conversation = await client.client.conversations.create()
|
||||
return self.get_session(service_session_id=conversation.id, session_id=session_id)
|
||||
|
||||
@override
|
||||
|
||||
@@ -17,7 +17,7 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import IS_TELEMETRY_ENABLED, get_user_agent
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
@@ -56,6 +56,11 @@ from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
|
||||
|
||||
from ._feature_usage import (
|
||||
FeatureIndex,
|
||||
create_feature_usage_policy,
|
||||
create_foundry_feature_usage_http_client,
|
||||
)
|
||||
from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
@@ -151,6 +156,7 @@ class RawFoundryChatClient(
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry"
|
||||
SUPPORTS_RICH_FUNCTION_OUTPUT: ClassVar[bool] = False
|
||||
_FEATURE_USAGE_INDEX: ClassVar[int | None] = FeatureIndex.FOUNDRY_CHAT_CLIENT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -203,12 +209,13 @@ class RawFoundryChatClient(
|
||||
|
||||
project_endpoint = foundry_settings.get("project_endpoint")
|
||||
|
||||
owns_project_client = project_client is None
|
||||
if project_endpoint is None and project_client is None:
|
||||
raise ValueError(
|
||||
"Either 'project_endpoint' or 'project_client' is required. "
|
||||
"Set project_endpoint via parameter or 'FOUNDRY_PROJECT_ENDPOINT' environment variable."
|
||||
)
|
||||
if not project_client:
|
||||
if project_client is None:
|
||||
if not project_endpoint:
|
||||
raise ValueError(
|
||||
"Azure AI project endpoint is required. Set via 'project_endpoint' parameter "
|
||||
@@ -220,8 +227,10 @@ class RawFoundryChatClient(
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": project_endpoint,
|
||||
"credential": credential,
|
||||
"user_agent": get_user_agent(),
|
||||
"per_retry_policies": [create_feature_usage_policy()],
|
||||
}
|
||||
if IS_TELEMETRY_ENABLED:
|
||||
project_client_kwargs["user_agent"] = get_user_agent()
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
@@ -229,6 +238,8 @@ class RawFoundryChatClient(
|
||||
openai_kwargs: dict[str, Any] = {}
|
||||
if default_headers:
|
||||
openai_kwargs["default_headers"] = default_headers
|
||||
if owns_project_client:
|
||||
openai_kwargs["http_client"] = create_foundry_feature_usage_http_client()
|
||||
|
||||
super().__init__(
|
||||
model=resolved_model,
|
||||
|
||||
@@ -17,11 +17,14 @@ from agent_framework import (
|
||||
UsageDetails,
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._telemetry import IS_TELEMETRY_ENABLED, get_user_agent, mark_feature_used
|
||||
from agent_framework.observability import EmbeddingTelemetryLayer
|
||||
from azure.ai.inference.aio import EmbeddingsClient, ImageEmbeddingsClient
|
||||
from azure.ai.inference.models import ImageEmbeddingInput
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
|
||||
from ._feature_usage import FeatureIndex, create_feature_usage_policy
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
@@ -151,13 +154,19 @@ class RawFoundryEmbeddingClient(
|
||||
if credential is None and text_client is None and image_client is None:
|
||||
raise ValueError("Either 'api_key', 'credential', or pre-configured client(s) must be provided.")
|
||||
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"endpoint": resolved_endpoint,
|
||||
"credential": credential,
|
||||
}
|
||||
if IS_TELEMETRY_ENABLED:
|
||||
client_kwargs["user_agent"] = get_user_agent()
|
||||
self._text_client = text_client or EmbeddingsClient(
|
||||
endpoint=resolved_endpoint, # type: ignore[arg-type]
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
**client_kwargs,
|
||||
per_retry_policies=[create_feature_usage_policy()],
|
||||
)
|
||||
self._image_client = image_client or ImageEmbeddingsClient(
|
||||
endpoint=resolved_endpoint, # type: ignore[arg-type]
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
**client_kwargs,
|
||||
per_retry_policies=[create_feature_usage_policy()],
|
||||
)
|
||||
self._endpoint = resolved_endpoint
|
||||
super().__init__(additional_properties=additional_properties)
|
||||
@@ -206,6 +215,7 @@ class RawFoundryEmbeddingClient(
|
||||
"""
|
||||
if not values:
|
||||
return GeneratedEmbeddings([], options=options)
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_EMBEDDING)
|
||||
|
||||
opts: dict[str, Any] = dict(options) if options else {}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
from agent_framework._telemetry import (
|
||||
USER_AGENT_KEY,
|
||||
apply_feature_token,
|
||||
remove_feature_token,
|
||||
)
|
||||
from agent_framework_openai._feature_usage import (
|
||||
_is_approved_origin, # pyright: ignore[reportPrivateUsage]
|
||||
create_feature_usage_http_client,
|
||||
)
|
||||
from azure.core.pipeline.policies import SansIOHTTPPolicy
|
||||
from openai import DefaultAsyncHttpxClient
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Foundry-owned feature-usage indexes."""
|
||||
|
||||
FOUNDRY_CHAT_CLIENT = 48
|
||||
FOUNDRY_AGENT = 49
|
||||
FOUNDRY_MEMORY = 50
|
||||
FOUNDRY_EMBEDDING = 51
|
||||
FOUNDRY_EVALS = 52
|
||||
|
||||
|
||||
_FOUNDRY_ORIGIN_SUFFIXES = (
|
||||
"inference.ai.azure.com",
|
||||
"services.ai.azure.com",
|
||||
)
|
||||
|
||||
|
||||
def create_foundry_feature_usage_http_client() -> DefaultAsyncHttpxClient:
|
||||
"""Create an OpenAI SDK client for approved Foundry origins."""
|
||||
return create_feature_usage_http_client(approved_origin_suffixes=_FOUNDRY_ORIGIN_SUFFIXES)
|
||||
|
||||
|
||||
def create_feature_usage_policy() -> "FeatureUsagePolicy":
|
||||
"""Create the destination-aware policy that stamps each actual request hop."""
|
||||
return FeatureUsagePolicy()
|
||||
|
||||
|
||||
class FeatureUsagePolicy(SansIOHTTPPolicy[Any, Any]):
|
||||
"""Refresh or remove the feature token based on the actual Azure request origin."""
|
||||
|
||||
def on_request(self, request: Any) -> None:
|
||||
"""Apply destination-aware feature stamping to the current request hop."""
|
||||
headers = request.http_request.headers
|
||||
user_agent = headers.get(USER_AGENT_KEY)
|
||||
if not isinstance(user_agent, str):
|
||||
return
|
||||
headers[USER_AGENT_KEY] = (
|
||||
apply_feature_token(user_agent)
|
||||
if _is_approved_origin(request.http_request.url, _FOUNDRY_ORIGIN_SUFFIXES)
|
||||
else remove_feature_token(user_agent)
|
||||
)
|
||||
@@ -43,9 +43,11 @@ from agent_framework._evaluation import (
|
||||
RubricScore,
|
||||
)
|
||||
from agent_framework._feature_stage import ExperimentalFeature, experimental
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from ._chat_client import FoundryChatClient
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
@@ -716,7 +718,14 @@ async def _evaluate_via_responses_impl(
|
||||
data_source=data_source, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
return await _poll_eval_run(client, eval_obj.id, run.id, poll_interval, timeout, provider=provider)
|
||||
return await _poll_eval_run(
|
||||
client,
|
||||
eval_obj.id,
|
||||
run.id,
|
||||
poll_interval,
|
||||
timeout,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -879,6 +888,7 @@ class FoundryEvals:
|
||||
Returns:
|
||||
``EvalResults`` with status, counts, and portal link.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_EVALS)
|
||||
# Resolve evaluators with auto-detection
|
||||
resolved = _resolve_default_evaluators(self._evaluators, items=items)
|
||||
# Filter tool evaluators if items don't have tools
|
||||
@@ -1023,6 +1033,7 @@ async def evaluate_traces(
|
||||
)
|
||||
"""
|
||||
oai_client = _resolve_openai_client(client, project_client)
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_EVALS)
|
||||
resolved_evaluators = _resolve_default_evaluators(evaluators)
|
||||
|
||||
if response_ids:
|
||||
@@ -1060,7 +1071,13 @@ async def evaluate_traces(
|
||||
data_source=trace_source, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
return await _poll_eval_run(oai_client, eval_obj.id, run.id, poll_interval, timeout)
|
||||
return await _poll_eval_run(
|
||||
oai_client,
|
||||
eval_obj.id,
|
||||
run.id,
|
||||
poll_interval,
|
||||
timeout,
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.EVALS)
|
||||
@@ -1109,6 +1126,7 @@ async def evaluate_foundry_target(
|
||||
if "type" not in target:
|
||||
raise ValueError("target dict must include a 'type' key (e.g., 'azure_ai_agent').")
|
||||
oai_client = _resolve_openai_client(client, project_client)
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_EVALS)
|
||||
resolved_evaluators = _resolve_default_evaluators(evaluators)
|
||||
|
||||
eval_obj = await oai_client.evals.create(
|
||||
@@ -1135,4 +1153,10 @@ async def evaluate_foundry_target(
|
||||
data_source=data_source, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
return await _poll_eval_run(oai_client, eval_obj.id, run.id, poll_interval, timeout)
|
||||
return await _poll_eval_run(
|
||||
oai_client,
|
||||
eval_obj.id,
|
||||
run.id,
|
||||
poll_interval,
|
||||
timeout,
|
||||
)
|
||||
|
||||
@@ -20,12 +20,14 @@ from agent_framework import (
|
||||
SessionContext,
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import IS_TELEMETRY_ENABLED, get_user_agent, mark_feature_used
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from openai.types.responses import ResponseInputItemParam
|
||||
|
||||
from ._feature_usage import FeatureIndex, create_feature_usage_policy
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self, TypedDict # pragma: no cover
|
||||
else:
|
||||
@@ -119,8 +121,10 @@ class FoundryMemoryProvider(ContextProvider):
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": resolved_endpoint,
|
||||
"credential": credential,
|
||||
"user_agent": get_user_agent(),
|
||||
"per_retry_policies": [create_feature_usage_policy()],
|
||||
}
|
||||
if IS_TELEMETRY_ENABLED:
|
||||
project_client_kwargs["user_agent"] = get_user_agent()
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
project_client = AIProjectClient(**project_client_kwargs)
|
||||
@@ -164,6 +168,7 @@ class FoundryMemoryProvider(ContextProvider):
|
||||
2. Searches for contextual memories based on input messages
|
||||
3. Combines and injects memories into the context
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_MEMORY)
|
||||
# On first run, retrieve static memories (user profile memories)
|
||||
if not state.get("initialized"):
|
||||
try:
|
||||
|
||||
@@ -9,7 +9,7 @@ import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
@@ -31,6 +31,7 @@ from agent_framework import (
|
||||
tool,
|
||||
)
|
||||
from agent_framework_openai._chat_client import RawOpenAIChatClient
|
||||
from agent_framework_openai._feature_usage import FeatureIndex as OpenAIFeatureIndex
|
||||
from azure.ai.projects import models as projects_models
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity import AzureCliCredential
|
||||
@@ -44,6 +45,7 @@ from agent_framework_foundry._agent import (
|
||||
_FoundryAgentChatClient,
|
||||
)
|
||||
from agent_framework_foundry._chat_client import FoundryChatClient
|
||||
from agent_framework_foundry._feature_usage import FeatureIndex, FeatureUsagePolicy
|
||||
|
||||
skip_if_foundry_agent_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/")
|
||||
@@ -60,6 +62,11 @@ _FOUNDRY_AZURE_AI_SEARCH_MODEL_ENV_VARS = (
|
||||
)
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_does_not_mark_openai_feature() -> None:
|
||||
assert RawOpenAIChatClient._FEATURE_USAGE_INDEX is OpenAIFeatureIndex.OPENAI
|
||||
assert RawFoundryAgentChatClient._FEATURE_USAGE_INDEX is FeatureIndex.FOUNDRY_AGENT
|
||||
|
||||
|
||||
def _get_foundry_azure_ai_search_model() -> str | None:
|
||||
"""Return the model/deployment to use for local Azure AI Search integration validation."""
|
||||
return next((os.environ[key] for key in _FOUNDRY_AZURE_AI_SEARCH_MODEL_ENV_VARS if os.getenv(key)), None)
|
||||
@@ -119,6 +126,24 @@ def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None:
|
||||
mock_project.get_openai_client.assert_called_once_with()
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_creates_project_client_with_feature_policy() -> None:
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
with patch("agent_framework_foundry._agent.AIProjectClient", return_value=mock_project) as factory:
|
||||
RawFoundryAgentChatClient(
|
||||
project_endpoint="https://test-project.services.ai.azure.com",
|
||||
credential=MagicMock(),
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
policies = factory.call_args.kwargs["per_retry_policies"]
|
||||
assert len(policies) == 1
|
||||
assert isinstance(policies[0], FeatureUsagePolicy)
|
||||
assert "custom_hook_policy" not in factory.call_args.kwargs
|
||||
mock_project.get_openai_client.assert_called_once_with(http_client=ANY)
|
||||
|
||||
|
||||
async def test_foundry_agent_basic_call_does_not_request_unsupported_encrypted_reasoning() -> None:
|
||||
"""A Foundry agent call must not opt into encrypted reasoning unless the caller requests it."""
|
||||
mock_response = MagicMock()
|
||||
@@ -1129,12 +1154,13 @@ async def test_foundry_agent_create_conversation_returns_agent_session() -> None
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = openai_client
|
||||
agent = FoundryAgent(project_client=mock_project, agent_name="test-agent")
|
||||
mock_project.get_openai_client.reset_mock()
|
||||
|
||||
session = await agent.create_conversation()
|
||||
|
||||
assert isinstance(session, AgentSession)
|
||||
assert session.service_session_id == "conv_123"
|
||||
mock_project.get_openai_client.assert_called()
|
||||
mock_project.get_openai_client.assert_not_called()
|
||||
openai_client.conversations.create.assert_awaited_once_with()
|
||||
|
||||
|
||||
@@ -1146,11 +1172,13 @@ async def test_foundry_agent_create_conversation_accepts_local_session_id() -> N
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = openai_client
|
||||
agent = FoundryAgent(project_client=mock_project, agent_name="test-agent")
|
||||
mock_project.get_openai_client.reset_mock()
|
||||
|
||||
session = await agent.create_conversation(session_id="local-session")
|
||||
|
||||
assert session.session_id == "local-session"
|
||||
assert session.service_session_id == "conv_123"
|
||||
mock_project.get_openai_client.assert_not_called()
|
||||
|
||||
|
||||
def test_foundry_agent_init() -> None:
|
||||
|
||||
@@ -9,22 +9,27 @@ import warnings
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
import agent_framework._telemetry as telemetry
|
||||
import pytest
|
||||
from agent_framework import Agent, ChatResponse, Content, Message, SupportsChatGetResponse, tool
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import get_user_agent, mark_feature_used
|
||||
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
|
||||
from agent_framework_openai import OpenAIContentFilterException
|
||||
from agent_framework_openai._chat_client import RawOpenAIChatClient
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.core.pipeline import Pipeline
|
||||
from azure.core.pipeline.policies import RedirectPolicy, UserAgentPolicy
|
||||
from azure.core.pipeline.transport import HttpRequest, HttpResponse, HttpTransport
|
||||
from azure.identity import AzureCliCredential
|
||||
from openai import BadRequestError
|
||||
from pydantic import BaseModel
|
||||
from pytest import param
|
||||
|
||||
from agent_framework_foundry import FoundryChatClient, RawFoundryChatClient
|
||||
from agent_framework_foundry._feature_usage import FeatureIndex, FeatureUsagePolicy
|
||||
|
||||
|
||||
class OutputStruct(BaseModel):
|
||||
@@ -34,6 +39,74 @@ class OutputStruct(BaseModel):
|
||||
weather: str | None = None
|
||||
|
||||
|
||||
def test_foundry_feature_usage_policy_refreshes_user_agent() -> None:
|
||||
with telemetry._feature_mask_lock:
|
||||
telemetry._feature_mask = 0
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_CHAT_CLIENT)
|
||||
request = MagicMock()
|
||||
request.http_request.url = "https://project.services.ai.azure.com/api/projects/test"
|
||||
request.http_request.headers = {"User-Agent": "azsdk-python-ai-projects/1.0 agent-framework-python/1.0"}
|
||||
FeatureUsagePolicy().on_request(request)
|
||||
|
||||
assert request.http_request.headers["User-Agent"] == (
|
||||
"azsdk-python-ai-projects/1.0 agent-framework-python/1.0 (feat=v1.1000000000000)"
|
||||
)
|
||||
|
||||
|
||||
def test_foundry_feature_usage_policy_removes_token_on_cross_origin_redirect() -> None:
|
||||
class _Response(HttpResponse):
|
||||
def body(self) -> bytes:
|
||||
return b""
|
||||
|
||||
class _RedirectTransport(HttpTransport[HttpRequest, HttpResponse]):
|
||||
def __init__(self) -> None:
|
||||
self.sent_headers: list[dict[str, str]] = []
|
||||
|
||||
def __enter__(self) -> _RedirectTransport:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self.close()
|
||||
|
||||
def open(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def send(self, request: HttpRequest, **kwargs: Any) -> HttpResponse:
|
||||
self.sent_headers.append(dict(request.headers))
|
||||
response = _Response(request, None)
|
||||
if len(self.sent_headers) == 1:
|
||||
response.status_code = 302
|
||||
response.headers = {"location": "https://example.com/redirected"}
|
||||
else:
|
||||
response.status_code = 200
|
||||
response.headers = {}
|
||||
return response
|
||||
|
||||
with telemetry._feature_mask_lock:
|
||||
telemetry._feature_mask = 0
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_CHAT_CLIENT)
|
||||
transport = _RedirectTransport()
|
||||
pipeline = cast(Any, Pipeline)(
|
||||
transport, [UserAgentPolicy(user_agent=get_user_agent()), RedirectPolicy(), FeatureUsagePolicy()]
|
||||
)
|
||||
|
||||
pipeline.run(HttpRequest("GET", "https://project.services.ai.azure.com/api/projects/test"))
|
||||
|
||||
assert "(feat=v1." in transport.sent_headers[0]["User-Agent"]
|
||||
assert "(feat=v1." not in transport.sent_headers[1]["User-Agent"]
|
||||
|
||||
|
||||
def test_foundry_feature_index_does_not_own_toolbox() -> None:
|
||||
assert not hasattr(FeatureIndex, "FOUNDRY_TOOLBOX")
|
||||
|
||||
|
||||
def test_raw_foundry_chat_client_owns_foundry_feature_bit() -> None:
|
||||
assert RawFoundryChatClient._FEATURE_USAGE_INDEX is FeatureIndex.FOUNDRY_CHAT_CLIENT
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
async def get_weather(location: Annotated[str, "The location as a city name"]) -> str:
|
||||
"""Get the current weather in a given location."""
|
||||
@@ -157,6 +230,7 @@ def test_init() -> None:
|
||||
assert client.model == _TEST_FOUNDRY_MODEL
|
||||
assert client.project_client is mock_project_client
|
||||
assert isinstance(client, SupportsChatGetResponse)
|
||||
mock_project_client.get_openai_client.assert_called_once_with()
|
||||
|
||||
|
||||
def test_raw_foundry_chat_client_init_uses_explicit_parameters() -> None:
|
||||
@@ -196,6 +270,7 @@ def test_init_with_default_header() -> None:
|
||||
assert client.default_headers is not None
|
||||
assert key in client.default_headers
|
||||
assert client.default_headers[key] == value
|
||||
project_client.get_openai_client.assert_called_once_with(default_headers=default_headers)
|
||||
|
||||
|
||||
def test_init_with_project_endpoint_creates_project_client() -> None:
|
||||
@@ -218,6 +293,11 @@ def test_init_with_project_endpoint_creates_project_client() -> None:
|
||||
assert factory.call_args.kwargs["credential"] is credential
|
||||
assert factory.call_args.kwargs["allow_preview"] is True
|
||||
assert factory.call_args.kwargs["user_agent"] == get_user_agent()
|
||||
policies = factory.call_args.kwargs["per_retry_policies"]
|
||||
assert len(policies) == 1
|
||||
assert isinstance(policies[0], FeatureUsagePolicy)
|
||||
assert "custom_hook_policy" not in factory.call_args.kwargs
|
||||
project_client.get_openai_client.assert_called_once_with(http_client=ANY)
|
||||
|
||||
|
||||
def test_init_with_empty_model_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@@ -5,10 +5,11 @@ from __future__ import annotations
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import Content
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
|
||||
from agent_framework_foundry import (
|
||||
FoundryEmbeddingClient,
|
||||
@@ -200,12 +201,24 @@ class TestRawFoundryEmbeddingClient:
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
|
||||
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
|
||||
patch("agent_framework_foundry._embedding_client.EmbeddingsClient") as text_client_type,
|
||||
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient") as image_client_type,
|
||||
):
|
||||
client = RawFoundryEmbeddingClient()
|
||||
assert client.model == "env-model"
|
||||
assert client.image_model == "env-model" # falls back to model
|
||||
text_client_type.assert_called_once_with(
|
||||
endpoint="https://env.inference.ai.azure.com",
|
||||
credential=ANY,
|
||||
user_agent=get_user_agent(),
|
||||
per_retry_policies=[ANY],
|
||||
)
|
||||
image_client_type.assert_called_once_with(
|
||||
endpoint="https://env.inference.ai.azure.com",
|
||||
credential=ANY,
|
||||
user_agent=get_user_agent(),
|
||||
per_retry_policies=[ANY],
|
||||
)
|
||||
|
||||
def test_image_model_from_env(self) -> None:
|
||||
"""image_model is loaded from its own environment variable."""
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, Message
|
||||
@@ -97,6 +97,7 @@ def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMoc
|
||||
credential=mock_credential,
|
||||
allow_preview=True,
|
||||
user_agent=get_user_agent(),
|
||||
per_retry_policies=[ANY],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -923,7 +923,7 @@ class TestFoundryEvals:
|
||||
mock_project.get_openai_client.return_value = mock_oai
|
||||
fe = FoundryEvals(project_client=mock_project, model="gpt-4o")
|
||||
assert fe.name == "Microsoft Foundry"
|
||||
mock_project.get_openai_client.assert_called_once()
|
||||
mock_project.get_openai_client.assert_called_once_with()
|
||||
|
||||
def test_constructor_no_client_auto_creates_from_env(self) -> None:
|
||||
"""When no client/project_client given, auto-creates FoundryChatClient from env."""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Foundry hosting-owned feature-usage indexes."""
|
||||
|
||||
FOUNDRY_TOOLBOX = 53
|
||||
FOUNDRY_HOSTING = 55
|
||||
@@ -1,12 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework import AgentSession, SupportsAgentRun
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from azure.ai.agentserver.core import get_request_context
|
||||
from azure.ai.agentserver.invocations import InvocationAgentServerHost
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, StreamingResponse
|
||||
from typing_extensions import Any, AsyncGenerator
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
|
||||
class InvocationsHostServer(InvocationAgentServerHost):
|
||||
"""An invocations server host for an agent."""
|
||||
@@ -34,6 +37,7 @@ class InvocationsHostServer(InvocationAgentServerHost):
|
||||
self._agent = agent
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self.invoke_handler(self._handle_invoke)
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_HOSTING)
|
||||
|
||||
def _partition_key(self) -> str:
|
||||
"""Get the partition key for the current request.
|
||||
|
||||
@@ -26,6 +26,7 @@ from agent_framework import (
|
||||
SupportsAgentRun,
|
||||
WorkflowAgent,
|
||||
)
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework.exceptions import AgentFrameworkException
|
||||
from azure.ai.agentserver.responses import (
|
||||
ResponseContext,
|
||||
@@ -115,6 +116,8 @@ from azure.ai.agentserver.responses.streaming._builders import (
|
||||
from mcp import McpError
|
||||
from typing_extensions import Any
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE = f"{MessageRole.__module__}:{MessageRole.__qualname__}"
|
||||
@@ -485,6 +488,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
self._agent_init_lock = asyncio.Lock()
|
||||
self.shutdown_handler(self._cleanup_agent)
|
||||
self.response_handler(self._handle_response)
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_HOSTING)
|
||||
|
||||
async def _ensure_agent_ready(self) -> None:
|
||||
"""Lazily enter the agent's async context exactly once.
|
||||
|
||||
@@ -19,9 +19,12 @@ from agent_framework import (
|
||||
SkillsSource,
|
||||
SkillsSourceContext,
|
||||
)
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from azure.ai.agentserver.core import get_request_context
|
||||
from typing_extensions import override
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from datetime import timedelta
|
||||
@@ -218,6 +221,12 @@ class FoundryToolbox(MCPStreamableHTTPTool):
|
||||
load_tools=load_tools,
|
||||
)
|
||||
|
||||
@override
|
||||
async def connect(self, *, reset: bool = False) -> None:
|
||||
"""Connect to the toolbox and mark its first meaningful activation."""
|
||||
await super().connect(reset=reset)
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_TOOLBOX)
|
||||
|
||||
@override
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
"""Get an authenticated MCP HTTP client.
|
||||
|
||||
@@ -9,11 +9,11 @@ from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from agent_framework import SkillsProvider, SkillsSourceContext, SupportsAgentRun
|
||||
from agent_framework import MCPStreamableHTTPTool, SkillsProvider, SkillsSourceContext, SupportsAgentRun
|
||||
from azure.ai.agentserver.core import (
|
||||
FoundryAgentRequestContext,
|
||||
reset_request_context,
|
||||
@@ -21,6 +21,7 @@ from azure.ai.agentserver.core import (
|
||||
)
|
||||
|
||||
from agent_framework_foundry_hosting import FoundryToolbox
|
||||
from agent_framework_foundry_hosting._feature_usage import FeatureIndex
|
||||
from agent_framework_foundry_hosting._toolbox import (
|
||||
_FoundryToolboxSkillsSource,
|
||||
_resolve_toolbox_endpoint,
|
||||
@@ -119,6 +120,27 @@ def test_init_derives_name_and_defaults() -> None:
|
||||
assert toolbox.load_prompts_flag is False
|
||||
|
||||
|
||||
def test_toolbox_owns_feature_index_53() -> None:
|
||||
assert FeatureIndex.FOUNDRY_TOOLBOX == 53
|
||||
|
||||
|
||||
async def test_toolbox_marks_feature_on_successful_connect_not_construction() -> None:
|
||||
with (
|
||||
patch.object(MCPStreamableHTTPTool, "connect", new=AsyncMock()) as connect,
|
||||
patch("agent_framework_foundry_hosting._toolbox.mark_feature_used") as mark_used,
|
||||
):
|
||||
toolbox = FoundryToolbox(
|
||||
_FakeCredential(), # type: ignore
|
||||
url="https://h/toolboxes/sales/mcp?api-version=v1",
|
||||
)
|
||||
mark_used.assert_not_called()
|
||||
|
||||
await toolbox.connect()
|
||||
|
||||
connect.assert_awaited_once_with(reset=False)
|
||||
mark_used.assert_called_once_with(FeatureIndex.FOUNDRY_TOOLBOX)
|
||||
|
||||
|
||||
async def test_auth_flow_injects_bearer_token() -> None:
|
||||
cred = _FakeCredential("abc123")
|
||||
auth = _ToolboxAuth(cred, "https://ai.azure.com/.default") # type: ignore
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Foundry Local-owned feature-usage indexes."""
|
||||
|
||||
FOUNDRY_LOCAL = 54
|
||||
+5
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from typing import Any, Generic, Literal, cast, overload
|
||||
from typing import Any, ClassVar, Generic, Literal, cast, overload
|
||||
|
||||
from agent_framework import (
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
@@ -27,6 +27,8 @@ from foundry_local.models import DeviceType, FoundryModelInfo
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
@@ -139,6 +141,8 @@ class FoundryLocalClient(
|
||||
):
|
||||
"""Foundry Local Chat completion class with middleware, telemetry, and function invocation support."""
|
||||
|
||||
_FEATURE_USAGE_INDEX: ClassVar[int | None] = FeatureIndex.FOUNDRY_LOCAL
|
||||
|
||||
@overload
|
||||
def get_response(
|
||||
self,
|
||||
|
||||
@@ -9,11 +9,16 @@ from agent_framework._settings import load_settings
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
from agent_framework.foundry import FoundryLocalClient
|
||||
|
||||
from agent_framework_foundry_local._feature_usage import FeatureIndex
|
||||
from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings
|
||||
|
||||
# Settings Tests
|
||||
|
||||
|
||||
def test_foundry_local_owns_foundry_local_feature() -> None:
|
||||
assert FoundryLocalClient._FEATURE_USAGE_INDEX is FeatureIndex.FOUNDRY_LOCAL
|
||||
|
||||
|
||||
def test_foundry_local_settings_init_from_env(foundry_local_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test FoundryLocalSettings initialization from environment variables."""
|
||||
settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_")
|
||||
|
||||
@@ -29,7 +29,7 @@ from agent_framework import (
|
||||
validate_tool_mode,
|
||||
)
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._telemetry import get_user_agent, mark_feature_used
|
||||
from agent_framework._types import _get_data_bytes # type: ignore[reportPrivateUsage]
|
||||
from agent_framework.exceptions import ContentError
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
@@ -38,6 +38,8 @@ from google.auth.credentials import Credentials
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # pragma: no cover
|
||||
else:
|
||||
@@ -539,6 +541,7 @@ class RawGeminiChatClient(
|
||||
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
|
||||
validated = await self._validate_options(options)
|
||||
model, contents, config = self._prepare_request(messages, validated)
|
||||
mark_feature_used(FeatureIndex.GEMINI)
|
||||
generate_content_stream = cast(
|
||||
Callable[..., Awaitable[AsyncIterable[types.GenerateContentResponse]]],
|
||||
cast(Any, self._genai_client.aio.models).generate_content_stream,
|
||||
@@ -555,6 +558,7 @@ class RawGeminiChatClient(
|
||||
async def _get_response() -> ChatResponse:
|
||||
validated = await self._validate_options(options)
|
||||
model, contents, config = self._prepare_request(messages, validated)
|
||||
mark_feature_used(FeatureIndex.GEMINI)
|
||||
raw = await self._genai_client.aio.models.generate_content(model=model, contents=contents, config=config) # type: ignore[arg-type]
|
||||
return self._process_generate_response(raw, response_format=validated.get("response_format"))
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Gemini-owned feature-usage indexes."""
|
||||
|
||||
GEMINI = 59
|
||||
@@ -17,6 +17,7 @@ from pydantic import BaseModel
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, RawGeminiChatClient, ThinkingConfig
|
||||
from agent_framework_gemini._feature_usage import FeatureIndex
|
||||
|
||||
|
||||
def _has_gemini_integration_credentials() -> bool:
|
||||
@@ -369,8 +370,10 @@ async def test_get_response_returns_text() -> None:
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hello!")]))
|
||||
|
||||
response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])])
|
||||
with patch("agent_framework_gemini._chat_client.mark_feature_used") as mark_feature_used:
|
||||
response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])])
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.GEMINI)
|
||||
assert response.messages[0].text == "Hello!"
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from agent_framework import (
|
||||
normalize_messages,
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework._tools import FunctionTool, ToolTypes
|
||||
from agent_framework._types import (
|
||||
AgentRunInputs,
|
||||
@@ -38,6 +39,8 @@ from agent_framework._types import (
|
||||
from agent_framework.exceptions import AgentException, ContentError
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
@@ -666,6 +669,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
unsubscribe = copilot_session.on(usage_event_handler)
|
||||
try:
|
||||
mark_feature_used(FeatureIndex.GITHUB_COPILOT)
|
||||
response_event = await copilot_session.send_and_wait(prompt, attachments=attachments, timeout=timeout)
|
||||
except Exception as ex:
|
||||
raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
|
||||
@@ -852,6 +856,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
unsubscribe = copilot_session.on(event_handler)
|
||||
|
||||
try:
|
||||
mark_feature_used(FeatureIndex.GITHUB_COPILOT)
|
||||
await copilot_session.send(prompt, attachments=attachments)
|
||||
|
||||
while (item := await queue.get()) is not None:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""GitHub Copilot-owned feature-usage indexes."""
|
||||
|
||||
GITHUB_COPILOT = 64
|
||||
@@ -39,6 +39,7 @@ from copilot.session_events import (
|
||||
from copilot.tools import ToolInvocation, ToolResult
|
||||
|
||||
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions, RawGitHubCopilotAgent
|
||||
from agent_framework_github_copilot._feature_usage import FeatureIndex
|
||||
|
||||
|
||||
def copilot_options(options: GitHubCopilotOptions) -> GitHubCopilotOptions:
|
||||
@@ -431,8 +432,10 @@ class TestGitHubCopilotAgentRun:
|
||||
mock_session.send_and_wait.return_value = assistant_message_event
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
response = await agent.run("Hello")
|
||||
with patch("agent_framework_github_copilot._agent.mark_feature_used") as mark_feature_used:
|
||||
response = await agent.run("Hello")
|
||||
|
||||
mark_feature_used.assert_called_once_with(FeatureIndex.GITHUB_COPILOT)
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].role == "assistant"
|
||||
|
||||
@@ -21,12 +21,15 @@ from agent_framework import (
|
||||
Workflow,
|
||||
WorkflowRunResult,
|
||||
)
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework_hosting import AgentRunArgs
|
||||
from google.protobuf.json_format import MessageToDict, ParseDict
|
||||
from google.protobuf.struct_pb2 import Value
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic.errors import PydanticSchemaGenerationError
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
logger = logging.getLogger("agent_framework.hosting.a2a")
|
||||
|
||||
_BINARY_MODE = "application/octet-stream"
|
||||
@@ -189,6 +192,7 @@ def a2a_to_run(
|
||||
ValueError: If the message has no supported content parts or contains
|
||||
a part outside ``input_modes``.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.HOSTING_A2A)
|
||||
if input_modes is not None:
|
||||
_validate_part_modes(message.parts, input_modes, "input")
|
||||
|
||||
@@ -279,6 +283,7 @@ def a2a_from_run(
|
||||
ValueError: If Agent Framework data content contains an invalid data URI
|
||||
or produces a part outside ``output_modes``.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.HOSTING_A2A)
|
||||
items: Sequence[Message | AgentResponseUpdate] = result.messages if isinstance(result, AgentResponse) else [result]
|
||||
|
||||
parts: list[Part] = []
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""A2A hosting-owned feature-usage indexes."""
|
||||
|
||||
HOSTING_A2A = 84
|
||||
@@ -9,10 +9,12 @@ from collections.abc import Collection, Mapping
|
||||
from typing import Any, Generic, TypeVar, cast
|
||||
|
||||
from agent_framework import AgentResponse, Message, SupportsAgentRun
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework_hosting import AgentRunArgs, AgentState
|
||||
from mcp import types
|
||||
|
||||
from ._conversion import mcp_from_run, mcp_to_run
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
AgentT = TypeVar("AgentT", bound=SupportsAgentRun)
|
||||
|
||||
@@ -86,6 +88,7 @@ class AgentMCPTool(Generic[AgentT]):
|
||||
|
||||
async def list_tools(self) -> list[types.Tool]:
|
||||
"""Return the native MCP tool definition for the target agent."""
|
||||
mark_feature_used(FeatureIndex.HOSTING_MCP)
|
||||
target = await self.state.get_target()
|
||||
return [self._tool_for_target(target)]
|
||||
|
||||
@@ -145,6 +148,7 @@ class AgentMCPTool(Generic[AgentT]):
|
||||
Raises:
|
||||
ValueError: If the tool name or configured session id is invalid.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.HOSTING_MCP)
|
||||
target = await self.state.get_target()
|
||||
tool = self._tool_for_target(target)
|
||||
if name != tool.name:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""MCP hosting-owned feature-usage indexes."""
|
||||
|
||||
HOSTING_MCP = 85
|
||||
@@ -9,11 +9,13 @@ from collections.abc import Mapping
|
||||
from typing import Any, Generic, TypeVar, cast
|
||||
|
||||
from agent_framework import AgentResponse, Message, Workflow, WorkflowRunResult
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework_hosting import WorkflowState
|
||||
from mcp import types
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from ._conversion import mcp_from_run
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
WorkflowT = TypeVar("WorkflowT", bound=Workflow)
|
||||
|
||||
@@ -53,6 +55,7 @@ class WorkflowMCPTool(Generic[WorkflowT]):
|
||||
|
||||
async def list_tools(self) -> list[types.Tool]:
|
||||
"""Return the native MCP tool definition for the target workflow."""
|
||||
mark_feature_used(FeatureIndex.HOSTING_MCP)
|
||||
workflow = await self.state.get_target()
|
||||
return [self._tool_for_workflow(workflow)]
|
||||
|
||||
@@ -134,6 +137,7 @@ class WorkflowMCPTool(Generic[WorkflowT]):
|
||||
Raises:
|
||||
ValueError: If the tool name or workflow input contract is invalid.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.HOSTING_MCP)
|
||||
workflow = await self.state.get_target()
|
||||
tool = self._tool_for_workflow(workflow)
|
||||
if name != tool.name:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""OpenAI Responses hosting-owned feature-usage indexes."""
|
||||
|
||||
HOSTING_RESPONSES = 86
|
||||
@@ -21,6 +21,7 @@ from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, ChatOptions, Content, Message, ResponseStream
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework_hosting import AgentRunArgs
|
||||
from openai.types.responses import (
|
||||
Response as OpenAIResponse,
|
||||
@@ -37,6 +38,8 @@ from openai.types.responses import (
|
||||
)
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
_RESPONSE_OUTPUT_ITEM_ADAPTER: TypeAdapter[Any] = TypeAdapter(ResponseOutputItem)
|
||||
|
||||
# OpenAI Responses field name → Agent Framework ChatOptions field name.
|
||||
@@ -179,6 +182,7 @@ def responses_to_run(body: Mapping[str, Any]) -> AgentRunArgs:
|
||||
Raises:
|
||||
ValueError: If the request body has invalid ``input``.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.HOSTING_RESPONSES)
|
||||
messages = messages_from_responses_input(body.get("input"))
|
||||
options: dict[str, Any] = {}
|
||||
for key, value in body.items():
|
||||
@@ -211,6 +215,7 @@ def responses_from_run(
|
||||
Returns:
|
||||
Responses-compatible JSON payload.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.HOSTING_RESPONSES)
|
||||
output_items = _result_to_output_items(result, status="completed")
|
||||
response_kwargs: dict[str, Any] = {
|
||||
"id": response_id,
|
||||
@@ -894,6 +899,7 @@ async def responses_from_streaming_run(
|
||||
model: str | None = None
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
try:
|
||||
mark_feature_used(FeatureIndex.HOSTING_RESPONSES)
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
if model is None:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
"""Telegram hosting-owned feature-usage indexes."""
|
||||
|
||||
HOSTING_TELEGRAM = 87
|
||||
@@ -19,8 +19,11 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import ChatOptions, Content, Message
|
||||
from agent_framework._telemetry import mark_feature_used
|
||||
from agent_framework_hosting import AgentRunArgs
|
||||
|
||||
from ._feature_usage import FeatureIndex
|
||||
|
||||
# Telegram media fields whose objects carry a `file_id` (and, except photos,
|
||||
# a `mime_type`) directly, mapped to the MIME type Telegram uses when the
|
||||
# object omits `mime_type` (voice notes are always OGG/Opus, for example).
|
||||
@@ -321,6 +324,7 @@ async def telegram_to_run(
|
||||
ValueError: If the update has no actionable message/callback data, or
|
||||
a message has no text, caption, or resolvable media.
|
||||
"""
|
||||
mark_feature_used(FeatureIndex.HOSTING_TELEGRAM)
|
||||
message = _inner_message(update)
|
||||
if message is not None:
|
||||
contents = await _contents_from_message(message, resolve_file_url)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user