From 9235a23ac1f9c3ef990d93e458b9915833e725b3 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 12 Dec 2025 19:05:50 +0530 Subject: [PATCH 1/9] add agents hosting and auto populate functionality --- .../core/middleware/turn_context_baggage.py | 192 ------------------ .../README.md | 9 + .../observability/hosting/__init__.py | 6 + .../hosting/extensions/__init__.py | 2 + .../hosting/extensions/populate_baggage.py | 32 +++ .../extensions/populate_invoke_agent_scope.py | 80 ++++++++ .../observability/hosting/extensions/utils.py | 114 +++++++++++ .../pyproject.toml | 41 ++++ .../setup.py | 29 +++ pyproject.toml | 2 + uv.lock | 25 ++- 11 files changed, 330 insertions(+), 202 deletions(-) delete mode 100644 libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/README.md create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/__init__.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_baggage.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_invoke_agent_scope.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/utils.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/pyproject.toml create mode 100644 libraries/microsoft-agents-a365-observability-hosting/setup.py diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py deleted file mode 100644 index a0f528e5..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py +++ /dev/null @@ -1,192 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any, Iterator, Mapping - -from ..constants import ( - GEN_AI_AGENT_AUID_KEY, - GEN_AI_AGENT_DESCRIPTION_KEY, - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_NAME_KEY, - GEN_AI_AGENT_UPN_KEY, - GEN_AI_CALLER_ID_KEY, - GEN_AI_CALLER_NAME_KEY, - GEN_AI_CALLER_TENANT_ID_KEY, - GEN_AI_CALLER_UPN_KEY, - GEN_AI_CALLER_USER_ID_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_CONVERSATION_ITEM_LINK_KEY, - GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, - GEN_AI_EXECUTION_SOURCE_NAME_KEY, - GEN_AI_EXECUTION_TYPE_KEY, - TENANT_ID_KEY, -) -from ..execution_type import ExecutionType - -AGENT_ROLE = "agenticUser" -CHANNEL_ID_AGENTS = "agents" - - -def _safe_get(obj: Any, *names: str) -> Any: - """Attempt multiple attribute/dict keys; return first non-None.""" - for n in names: - if obj is None: - continue - # dict-like - if isinstance(obj, Mapping) and n in obj: - return obj[n] - # attribute-like (support both camelCase and snake_case lookups) - if hasattr(obj, n): - return getattr(obj, n) - return None - - -def _extract_channel_data(activity: Any) -> Mapping[str, Any] | None: - cd = _safe_get(activity, "channel_data") - if cd is None: - return None - if isinstance(cd, Mapping): - return cd - if isinstance(cd, str): - try: - return json.loads(cd) - except Exception: - return None - return None - - -def _iter_caller_pairs(activity: Any) -> Iterator[tuple[str, Any]]: - frm = _safe_get(activity, "from") - if not frm: - return - yield GEN_AI_CALLER_ID_KEY, _safe_get(frm, "id") - name = _safe_get(frm, "name") - yield GEN_AI_CALLER_NAME_KEY, name - # Reuse 'name' as UPN if no separate field - upn = _safe_get(frm, "upn") or name - yield GEN_AI_CALLER_UPN_KEY, upn - user_id = _safe_get(frm, "agentic_user_id", "aad_object_id") - yield GEN_AI_CALLER_USER_ID_KEY, user_id - tenant_id = _safe_get(frm, "tenant_id") - yield GEN_AI_CALLER_TENANT_ID_KEY, tenant_id - - -def _is_agentic(entity: Any) -> bool: - return bool( - _safe_get( - entity, - "agentic_user_id", - ) - or ( - (role := _safe_get(entity, "role", "Role")) - and isinstance(role, str) - and role.lower() == AGENT_ROLE.lower() - ) - ) - - -def _iter_execution_type_pair(activity: Any) -> Iterator[tuple[str, Any]]: - frm = _safe_get(activity, "from") - rec = _safe_get(activity, "recipient") - is_agentic_caller = _is_agentic(frm) - is_agentic_recipient = _is_agentic(rec) - exec_type = ( - ExecutionType.AGENT_TO_AGENT.value - if (is_agentic_caller and is_agentic_recipient) - else ExecutionType.HUMAN_TO_AGENT.value - ) - yield GEN_AI_EXECUTION_TYPE_KEY, exec_type - - -def _iter_target_agent_pairs(activity: Any) -> Iterator[tuple[str, Any]]: - rec = _safe_get(activity, "recipient") - if not rec: - return - yield GEN_AI_AGENT_ID_KEY, _safe_get(rec, "agentic_app_id") - yield GEN_AI_AGENT_NAME_KEY, _safe_get(rec, "name") - auid = _safe_get(rec, "agentic_user_id", "aad_object_id") - yield GEN_AI_AGENT_AUID_KEY, auid - yield GEN_AI_AGENT_UPN_KEY, _safe_get(rec, "upn", "name") - yield ( - GEN_AI_AGENT_DESCRIPTION_KEY, - _safe_get(rec, "role"), - ) - - -def _iter_tenant_id_pair(activity: Any) -> Iterator[tuple[str, Any]]: - rec = _safe_get(activity, "recipient") - tenant_id = _safe_get(rec, "tenant_id") - if not tenant_id: - cd_dict = _extract_channel_data(activity) - # channelData.tenant.id - try: - tenant_id = ( - cd_dict - and isinstance(cd_dict.get("tenant"), Mapping) - and cd_dict["tenant"].get("id") - ) - except Exception: - tenant_id = None - yield TENANT_ID_KEY, tenant_id - - -def _iter_source_metadata_pairs(activity: Any) -> Iterator[tuple[str, Any]]: - """ - Generate source metadata pairs from activity, handling both string and ChannelId object cases. - - :param activity: The activity object (Activity instance or dict) - :return: Iterator of (key, value) tuples for source metadata - """ - # Handle channel_id (can be string or ChannelId object) - channel_id = _safe_get(activity, "channel_id") - - # Extract channel name from either string or ChannelId object - channel_name = None - sub_channel = None - - if channel_id is not None: - if isinstance(channel_id, str): - # Direct string value - channel_name = channel_id - elif hasattr(channel_id, "channel"): - # ChannelId object - channel_name = channel_id.channel - sub_channel = getattr(channel_id, "sub_channel", None) - elif isinstance(channel_id, dict): - # Serialized ChannelId as dict - channel_name = channel_id.get("channel") - sub_channel = channel_id.get("sub_channel") - - # Yield channel name as source name - yield GEN_AI_EXECUTION_SOURCE_NAME_KEY, channel_name - yield GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, sub_channel - - -def _iter_conversation_pairs(activity: Any) -> Iterator[tuple[str, Any]]: - conv = _safe_get(activity, "conversation") - conversation_id = _safe_get(conv, "id") - - item_link = _safe_get(activity, "service_url") - - yield GEN_AI_CONVERSATION_ID_KEY, conversation_id - yield GEN_AI_CONVERSATION_ITEM_LINK_KEY, item_link - - -def _iter_all_pairs(turn_context: Any) -> Iterator[tuple[str, Any]]: - activity = _safe_get( - turn_context, - "activity", - ) - if not activity: - return - yield from _iter_caller_pairs(activity) - yield from _iter_execution_type_pair(activity) - yield from _iter_target_agent_pairs(activity) - yield from _iter_tenant_id_pair(activity) - yield from _iter_source_metadata_pairs(activity) - yield from _iter_conversation_pairs(activity) - - -def from_turn_context(turn_context: Any) -> dict: - """Populate builder with baggage values extracted from a turn context.""" - return dict(_iter_all_pairs(turn_context)) diff --git a/libraries/microsoft-agents-a365-observability-hosting/README.md b/libraries/microsoft-agents-a365-observability-hosting/README.md new file mode 100644 index 00000000..2b3ed82a --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/README.md @@ -0,0 +1,9 @@ +# Microsoft Agent 365 Observability Hosting Library + +This library provides hosting components for Agent 365 observability. + +## Installation + +```bash +pip install microsoft-agents-a365-observability-hosting +``` diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py new file mode 100644 index 00000000..16a6a631 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Microsoft Agent 365 Observability Hosting Library. +""" diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_baggage.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_baggage.py new file mode 100644 index 00000000..9f7267a4 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_baggage.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from microsoft_agents.hosting.core.turn_context import TurnContext + +from .utils import ( + get_caller_pairs, + get_conversation_pairs, + get_execution_type_pair, + get_source_metadata_pairs, + get_target_agent_pairs, + get_tenant_id_pair, +) + + +def _iter_all_pairs(turn_context: TurnContext) -> Iterator[tuple[str, Any]]: + activity = turn_context.activity + if not activity: + return + yield from get_caller_pairs(activity) + yield from get_execution_type_pair(activity) + yield from get_target_agent_pairs(activity) + yield from get_tenant_id_pair(activity) + yield from get_source_metadata_pairs(activity) + yield from get_conversation_pairs(activity) + + +def from_turn_context(turn_context: TurnContext) -> dict: + """Populate builder with baggage values extracted from a turn context.""" + return dict(_iter_all_pairs(turn_context)) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_invoke_agent_scope.py new file mode 100644 index 00000000..46f1365e --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_invoke_agent_scope.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from microsoft_agents_a365.observability.core.invoke_agent_scope import InvokeAgentScope + +from .utils import ( + get_caller_pairs, + get_conversation_pairs, + get_execution_type_pair, + get_source_metadata_pairs, + get_target_agent_pairs, + get_tenant_id_pair, +) + +if TYPE_CHECKING: + from microsoft_agents.hosting.core.turn_context import TurnContext + + +def populate_invoke_agent_scope_from_turn_context( + scope: InvokeAgentScope, turn_context: TurnContext +) -> InvokeAgentScope: + """ + Populate all supported InvokeAgentScope tags from the provided TurnContext. + :param scope: The InvokeAgentScope instance to populate. + :param turn_context: The TurnContext containing activity information. + :return: The updated InvokeAgentScope instance. + """ + if not turn_context: + raise ValueError("turn_context is required") + + if not turn_context.activity: + return scope + + activity = turn_context.activity + + set_caller_tags(scope, activity) + set_execution_type_tags(scope, activity) + set_target_agent_tags(scope, activity) + set_tenant_id_tags(scope, activity) + set_source_metadata_tags(scope, activity) + set_conversation_id_tags(scope, activity) + set_input_message_tags(scope, activity) + return scope + + +def set_caller_tags(scope: InvokeAgentScope, activity) -> None: + """Sets the caller-related attribute values from the Activity.""" + scope.record_attributes(get_caller_pairs(activity)) + + +def set_execution_type_tags(scope: InvokeAgentScope, activity) -> None: + """Sets the execution type tag based on caller and recipient agentic status.""" + scope.record_attributes(get_execution_type_pair(activity)) + + +def set_target_agent_tags(scope: InvokeAgentScope, activity) -> None: + """Sets the target agent-related tags from the Activity.""" + scope.record_attributes(get_target_agent_pairs(activity)) + + +def set_tenant_id_tags(scope: InvokeAgentScope, activity) -> None: + """Sets the tenant ID tag, extracting from ChannelData if necessary.""" + scope.record_attributes(get_tenant_id_pair(activity)) + + +def set_source_metadata_tags(scope: InvokeAgentScope, activity) -> None: + """Sets the source metadata tags from the Activity.""" + scope.record_attributes(get_source_metadata_pairs(activity)) + + +def set_conversation_id_tags(scope: InvokeAgentScope, activity) -> None: + """Sets the conversation ID and item link tags from the Activity.""" + scope.record_attributes(get_conversation_pairs(activity)) + + +def set_input_message_tags(scope: InvokeAgentScope, activity) -> None: + """Sets the input message tag from the Activity.""" + if activity.text: + scope.record_input_messages([activity.text]) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/utils.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/utils.py new file mode 100644 index 00000000..d112fad3 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/utils.py @@ -0,0 +1,114 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +from collections.abc import Iterator +from typing import Any + +from microsoft_agents.activity import Activity +from microsoft_agents_a365.observability.core.constants import ( + GEN_AI_AGENT_AUID_KEY, + GEN_AI_AGENT_DESCRIPTION_KEY, + GEN_AI_AGENT_ID_KEY, + GEN_AI_AGENT_NAME_KEY, + GEN_AI_AGENT_UPN_KEY, + GEN_AI_CALLER_ID_KEY, + GEN_AI_CALLER_NAME_KEY, + GEN_AI_CALLER_TENANT_ID_KEY, + GEN_AI_CALLER_UPN_KEY, + GEN_AI_CONVERSATION_ID_KEY, + GEN_AI_CONVERSATION_ITEM_LINK_KEY, + GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, + GEN_AI_EXECUTION_SOURCE_NAME_KEY, + GEN_AI_EXECUTION_TYPE_KEY, + TENANT_ID_KEY, +) +from microsoft_agents_a365.observability.core.execution_type import ExecutionType + +AGENT_ROLE = "agenticUser" + + +def _is_agentic(entity: Any) -> bool: + if not entity: + return False + return bool( + entity.agentic_user_id + or ((role := entity.role) and isinstance(role, str) and role.lower() == AGENT_ROLE.lower()) + ) + + +def get_caller_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: + frm = activity.from_property + if not frm: + return + yield GEN_AI_CALLER_ID_KEY, frm.aad_object_id + yield GEN_AI_CALLER_NAME_KEY, frm.name + yield GEN_AI_CALLER_UPN_KEY, frm.agentic_user_id + yield GEN_AI_CALLER_TENANT_ID_KEY, frm.tenant_id + + +def get_execution_type_pair(activity: Activity) -> Iterator[tuple[str, Any]]: + frm = activity.from_property + rec = activity.recipient + is_agentic_caller = _is_agentic(frm) + is_agentic_recipient = _is_agentic(rec) + exec_type = ( + ExecutionType.AGENT_TO_AGENT.value + if (is_agentic_caller and is_agentic_recipient) + else ExecutionType.HUMAN_TO_AGENT.value + ) + yield GEN_AI_EXECUTION_TYPE_KEY, exec_type + + +def get_target_agent_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: + rec = activity.recipient + if not rec: + return + yield GEN_AI_AGENT_ID_KEY, rec.agentic_app_id + yield GEN_AI_AGENT_NAME_KEY, rec.name + yield GEN_AI_AGENT_AUID_KEY, rec.aad_object_id + yield GEN_AI_AGENT_UPN_KEY, rec.agentic_user_id + yield ( + GEN_AI_AGENT_DESCRIPTION_KEY, + rec.role, + ) + + +def get_tenant_id_pair(activity: Activity) -> Iterator[tuple[str, Any]]: + yield TENANT_ID_KEY, activity.recipient.tenant_id + + +def get_source_metadata_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: + """ + Generate source metadata pairs from activity, handling both string and ChannelId object cases. + + :param activity: The activity object (Activity instance or dict) + :return: Iterator of (key, value) tuples for source metadata + """ + # Handle channel_id (can be string or ChannelId object) + channel_id = activity.channel_id + + # Extract channel name from either string or ChannelId object + channel_name = None + sub_channel = None + + if channel_id is not None: + if isinstance(channel_id, str): + # Direct string value + channel_name = channel_id + elif hasattr(channel_id, "channel"): + # ChannelId object + channel_name = channel_id.channel + sub_channel = channel_id.sub_channel + + # Yield channel name as source name + yield GEN_AI_EXECUTION_SOURCE_NAME_KEY, channel_name + yield GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, sub_channel + + +def get_conversation_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: + conv = activity.conversation + conversation_id = conv.id if conv else None + + item_link = activity.service_url + + yield GEN_AI_CONVERSATION_ID_KEY, conversation_id + yield GEN_AI_CONVERSATION_ITEM_LINK_KEY, item_link diff --git a/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml b/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml new file mode 100644 index 00000000..40f07a11 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["setuptools>=68", "wheel", "tzdata"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-a365-observability-hosting" +dynamic = ["version"] +authors = [ + { name = "Microsoft", email = "support@microsoft.com" }, +] +description = "Hosting components for Agent 365 observability" +readme = "README.md" +requires-python = ">=3.11" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: System :: Monitoring", +] +license = {text = "MIT"} +keywords = ["observability", "telemetry", "tracing", "opentelemetry", "monitoring", "ai", "agents", "hosting"] +dependencies = [ + "microsoft-agents-hosting-core >= 0.4.0, < 0.6.0", + "microsoft-agents-a365-observability-core >= 0.0.0", + "opentelemetry-api >= 1.36.0", +] + +[project.urls] +Homepage = "https://github.com/microsoft/Agent365-python" +Repository = "https://github.com/microsoft/Agent365-python" +Issues = "https://github.com/microsoft/Agent365-python/issues" +Documentation = "https://github.com/microsoft/Agent365-python/tree/main/libraries/microsoft-agents-a365-observability-hosting" + +[tool.setuptools.packages.find] +include = ["microsoft_agents_a365*"] +namespaces = true diff --git a/libraries/microsoft-agents-a365-observability-hosting/setup.py b/libraries/microsoft-agents-a365-observability-hosting/setup.py new file mode 100644 index 00000000..31002817 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/setup.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import sys +from os import environ +from pathlib import Path + +from setuptools import setup + +# Get version from environment variable set by CI/CD +package_version = environ.get("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "0.0.0") + +# Add versioning helper to path +helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" +sys.path.insert(0, str(helper_path)) + +from setup_utils import get_dynamic_dependencies + +# Use minimum version strategy: +# - Internal packages get: >= current_base_version (e.g., >= 0.1.0) +# - Automatically updates when you build new versions +dynamic_install_requires = get_dynamic_dependencies( + Path(__file__).parent, version_strategy="minimum" +) + +setup( + version=package_version, + install_requires=dynamic_install_requires, +) diff --git a/pyproject.toml b/pyproject.toml index aea1001f..4799b21e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ members = [ "libraries/microsoft-agents-a365-tooling-extensions-openai", "libraries/microsoft-agents-a365-tooling-extensions-semantickernel", "libraries/microsoft-agents-a365-tooling-extensions-agentframework", + "libraries/microsoft-agents-a365-observability-hosting", ] exclude = [ ".", @@ -28,6 +29,7 @@ microsoft-agents-a365-notifications = { workspace = true } microsoft-agents-a365-observability-core = { workspace = true } microsoft-agents-a365-runtime = { workspace = true } microsoft-agents-a365-tooling = { workspace = true } +microsoft-agents-a365-observability-hosting = { workspace = true } # Development dependencies for the entire workspace [tool.uv] diff --git a/uv.lock b/uv.lock index 709ead8f..af7d83ea 100644 --- a/uv.lock +++ b/uv.lock @@ -25,7 +25,7 @@ members = [ [manifest.dependency-groups] dev = [ - { name = "agent-framework-azure-ai", specifier = ">=0.1.0" }, + { name = "agent-framework-azure-ai", specifier = ">=1.0.0b251114" }, { name = "azure-identity", specifier = ">=1.12.0" }, { name = "openai", specifier = ">=1.0.0" }, { name = "openai-agents", specifier = ">=0.2.6" }, @@ -38,7 +38,7 @@ dev = [ [[package]] name = "agent-framework-azure-ai" -version = "1.0.0b251028" +version = "1.0.0b251114" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "agent-framework-core" }, @@ -46,9 +46,9 @@ dependencies = [ { name = "azure-ai-agents" }, { name = "azure-ai-projects" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/90/aff52df23aa2e5400ffd25b3ed5435f751d3ff1eac0d55070763d67c704b/agent_framework_azure_ai-1.0.0b251028.tar.gz", hash = "sha256:de0e910f611c144fcdea489755c572c2724adde67abf17d780010eb9e3e60d84", size = 27719, upload-time = "2025-10-28T19:39:21.829Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/30/269bcb459a13dfb42606698661fe57566bd9a385bc87261d8fa93d8f6e18/agent_framework_azure_ai-1.0.0b251114.tar.gz", hash = "sha256:86c5e197f5c5d8d17d8df905a5842d68f652f7f88bb53da601ffa305cdf23622", size = 17094, upload-time = "2025-11-15T01:01:28.607Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/c2/bd5673521fc954e20210b6c316b2317b1ea9098d4be63263defbac542e68/agent_framework_azure_ai-1.0.0b251028-py3-none-any.whl", hash = "sha256:977b47ef3b89ff000c2e00278389523295fafe755e69bcf040bda68068416fa1", size = 13907, upload-time = "2025-10-28T19:39:20.255Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d5/8f311634b41d55f649338dbd5eb63d69318227ab319b320cb06322c750fb/agent_framework_azure_ai-1.0.0b251114-py3-none-any.whl", hash = "sha256:ff0aade4bc86381e96e9c8d22e26d3d65c839103b9f686dee5196a21f78ccfbe", size = 18871, upload-time = "2025-11-15T01:01:27.604Z" }, ] [[package]] @@ -355,18 +355,17 @@ wheels = [ [[package]] name = "azure-ai-projects" -version = "1.0.0" +version = "2.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-ai-agents" }, { name = "azure-core" }, { name = "azure-storage-blob" }, { name = "isodate" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/95/9c04cb5f658c7f856026aa18432e0f0fa254ead2983a3574a0f5558a7234/azure_ai_projects-1.0.0.tar.gz", hash = "sha256:b5f03024ccf0fd543fbe0f5abcc74e45b15eccc1c71ab87fc71c63061d9fd63c", size = 130798, upload-time = "2025-07-31T02:09:27.912Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/96/ec17f99f5ced3d82876e89b4f950d8c7466c84d79016c5905b1c03b6c484/azure_ai_projects-2.0.0b2.tar.gz", hash = "sha256:4444cc49c799359b9c25d7f59c126862053cb591b63e69ffc640774b4ceb2b73", size = 369393, upload-time = "2025-11-15T06:17:46.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/db/7149cdf71e12d9737f186656176efc94943ead4f205671768c1549593efe/azure_ai_projects-1.0.0-py3-none-any.whl", hash = "sha256:81369ed7a2f84a65864f57d3fa153e16c30f411a1504d334e184fb070165a3fa", size = 115188, upload-time = "2025-07-31T02:09:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/41/d9a2b3eb33b4ffd9acfaa115cfd456e32d0c754227d6d78ec5d039ff75c2/azure_ai_projects-2.0.0b2-py3-none-any.whl", hash = "sha256:642496fdf9846c91f3557d39899d3893f0ce8f910334320686fc8f617492351d", size = 234023, upload-time = "2025-11-15T06:17:48.141Z" }, ] [[package]] @@ -1782,6 +1781,9 @@ provides-extras = ["dev", "test"] [[package]] name = "microsoft-agents-a365-runtime" source = { editable = "libraries/microsoft-agents-a365-runtime" } +dependencies = [ + { name = "pyjwt" }, +] [package.optional-dependencies] dev = [ @@ -1800,6 +1802,7 @@ test = [ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "pyjwt", specifier = ">=2.8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, @@ -1863,7 +1866,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "agent-framework-azure-ai", specifier = ">=0.1.0" }, + { name = "agent-framework-azure-ai", specifier = ">=1.0.0b251114" }, { name = "azure-identity", specifier = ">=1.12.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "microsoft-agents-a365-tooling", editable = "libraries/microsoft-agents-a365-tooling" }, @@ -1904,7 +1907,7 @@ test = [ [package.metadata] requires-dist = [ { name = "azure-ai-agents", specifier = ">=1.0.0b251001" }, - { name = "azure-ai-projects", specifier = ">=1.0.0" }, + { name = "azure-ai-projects", specifier = ">=2.0.0b1" }, { name = "azure-identity", specifier = ">=1.12.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "microsoft-agents-a365-tooling", editable = "libraries/microsoft-agents-a365-tooling" }, @@ -3611,6 +3614,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/fa/3234f913fe9a6525a7b97c6dad1f51e72b917e6872e051a5e2ffd8b16fbb/ruamel.yaml.clib-0.2.14-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:70eda7703b8126f5e52fcf276e6c0f40b0d314674f896fc58c47b0aef2b9ae83", size = 137970, upload-time = "2025-09-22T19:51:09.472Z" }, { url = "https://files.pythonhosted.org/packages/ef/ec/4edbf17ac2c87fa0845dd366ef8d5852b96eb58fcd65fc1ecf5fe27b4641/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a0cb71ccc6ef9ce36eecb6272c81afdc2f565950cdcec33ae8e6cd8f7fc86f27", size = 739639, upload-time = "2025-09-22T19:51:10.566Z" }, { url = "https://files.pythonhosted.org/packages/15/18/b0e1fafe59051de9e79cdd431863b03593ecfa8341c110affad7c8121efc/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7cb9ad1d525d40f7d87b6df7c0ff916a66bc52cb61b66ac1b2a16d0c1b07640", size = 764456, upload-time = "2025-09-22T19:51:11.736Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cd/150fdb96b8fab27fe08d8a59fe67554568727981806e6bc2677a16081ec7/ruamel_yaml_clib-0.2.14-cp314-cp314-win32.whl", hash = "sha256:9b4104bf43ca0cd4e6f738cb86326a3b2f6eef00f417bd1e7efb7bdffe74c539", size = 102394, upload-time = "2025-11-14T21:57:36.703Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/a3fa40084558c7e1dc9546385f22a93949c890a8b2e445b2ba43935f51da/ruamel_yaml_clib-0.2.14-cp314-cp314-win_amd64.whl", hash = "sha256:13997d7d354a9890ea1ec5937a219817464e5cc344805b37671562a401ca3008", size = 122673, upload-time = "2025-11-14T21:57:38.177Z" }, ] [[package]] From 5b702933ffda7cd3f608c16cd88ba79f2277f967 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 12 Dec 2025 20:41:42 +0530 Subject: [PATCH 2/9] rename folder --- .../hosting/{extensions => scope_helpers}/__init__.py | 0 .../hosting/{extensions => scope_helpers}/populate_baggage.py | 0 .../{extensions => scope_helpers}/populate_invoke_agent_scope.py | 0 .../observability/hosting/{extensions => scope_helpers}/utils.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/{extensions => scope_helpers}/__init__.py (100%) rename libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/{extensions => scope_helpers}/populate_baggage.py (100%) rename libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/{extensions => scope_helpers}/populate_invoke_agent_scope.py (100%) rename libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/{extensions => scope_helpers}/utils.py (100%) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/__init__.py similarity index 100% rename from libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/__init__.py rename to libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/__init__.py diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_baggage.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py similarity index 100% rename from libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_baggage.py rename to libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py similarity index 100% rename from libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/populate_invoke_agent_scope.py rename to libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/utils.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py similarity index 100% rename from libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/extensions/utils.py rename to libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py From 1b3602368b14b894d9f27f9cdd6b1192a10adaed Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 12 Dec 2025 21:29:54 +0530 Subject: [PATCH 3/9] ensure cycle dependencies are resolved --- .../core/middleware/baggage_builder.py | 9 --------- .../hosting/scope_helpers/populate_baggage.py | 16 +++++++++++++--- .../populate_invoke_agent_scope.py | 4 +--- .../setup.py | 3 ++- uv.lock | 17 +++++++++++++++++ 5 files changed, 33 insertions(+), 16 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py index 9736dd16..20f36642 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py @@ -31,7 +31,6 @@ ) from ..models.operation_source import OperationSource from ..utils import deprecated, validate_and_normalize_ip -from .turn_context_baggage import from_turn_context logger = logging.getLogger(__name__) @@ -232,14 +231,6 @@ def channel_links(self, value: str | None) -> "BaggageBuilder": self._set(GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, value) return self - def from_turn_context(self, turn_context: Any) -> "BaggageBuilder": - """ - Populate baggage from a turn_context (duck-typed). - Delegates to baggage_turn_context.from_turn_context. - """ - - return self.set_pairs(from_turn_context(turn_context)) - def set_pairs(self, pairs: Any) -> "BaggageBuilder": """ Accept dict or iterable of (k,v). diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py index 9f7267a4..6dbf807a 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py @@ -4,6 +4,7 @@ from typing import Any from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder from .utils import ( get_caller_pairs, @@ -27,6 +28,15 @@ def _iter_all_pairs(turn_context: TurnContext) -> Iterator[tuple[str, Any]]: yield from get_conversation_pairs(activity) -def from_turn_context(turn_context: TurnContext) -> dict: - """Populate builder with baggage values extracted from a turn context.""" - return dict(_iter_all_pairs(turn_context)) +def populate(builder: BaggageBuilder, turn_context: TurnContext) -> BaggageBuilder: + """Populate BaggageBuilder with baggage values extracted from a turn context. + + Args: + builder: The BaggageBuilder instance to populate + turn_context: The TurnContext containing activity information + + Returns: + The updated BaggageBuilder instance (for method chaining) + """ + builder.set_pairs(_iter_all_pairs(turn_context)) + return builder diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py index 46f1365e..4dba6286 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py @@ -17,9 +17,7 @@ from microsoft_agents.hosting.core.turn_context import TurnContext -def populate_invoke_agent_scope_from_turn_context( - scope: InvokeAgentScope, turn_context: TurnContext -) -> InvokeAgentScope: +def populate(scope: InvokeAgentScope, turn_context: TurnContext) -> InvokeAgentScope: """ Populate all supported InvokeAgentScope tags from the provided TurnContext. :param scope: The InvokeAgentScope instance to populate. diff --git a/libraries/microsoft-agents-a365-observability-hosting/setup.py b/libraries/microsoft-agents-a365-observability-hosting/setup.py index 31002817..9e538463 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/setup.py +++ b/libraries/microsoft-agents-a365-observability-hosting/setup.py @@ -20,7 +20,8 @@ # - Internal packages get: >= current_base_version (e.g., >= 0.1.0) # - Automatically updates when you build new versions dynamic_install_requires = get_dynamic_dependencies( - Path(__file__).parent, version_strategy="minimum" + use_compatible_release=False, # No upper bound + use_exact_match=False, # Not exact match ) setup( diff --git a/uv.lock b/uv.lock index af7d83ea..7d674874 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,7 @@ members = [ "microsoft-agents-a365-observability-extensions-langchain", "microsoft-agents-a365-observability-extensions-openai", "microsoft-agents-a365-observability-extensions-semantic-kernel", + "microsoft-agents-a365-observability-hosting", "microsoft-agents-a365-runtime", "microsoft-agents-a365-tooling", "microsoft-agents-a365-tooling-extensions-agentframework", @@ -1778,6 +1779,22 @@ requires-dist = [ ] provides-extras = ["dev", "test"] +[[package]] +name = "microsoft-agents-a365-observability-hosting" +source = { editable = "libraries/microsoft-agents-a365-observability-hosting" } +dependencies = [ + { name = "microsoft-agents-a365-observability-core" }, + { name = "microsoft-agents-hosting-core" }, + { name = "opentelemetry-api" }, +] + +[package.metadata] +requires-dist = [ + { name = "microsoft-agents-a365-observability-core", editable = "libraries/microsoft-agents-a365-observability-core" }, + { name = "microsoft-agents-hosting-core", specifier = ">=0.4.0,<0.6.0" }, + { name = "opentelemetry-api", specifier = ">=1.36.0" }, +] + [[package]] name = "microsoft-agents-a365-runtime" source = { editable = "libraries/microsoft-agents-a365-runtime" } From b7b2e33cd92c05738d41421b13468b8e51f3c7c7 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 12 Dec 2025 22:04:16 +0530 Subject: [PATCH 4/9] add tests --- tests/observability/hosting/__init__.py | 2 + .../hosting/test_populate_baggage.py | 37 +++ .../test_populate_invoke_agent_scope.py | 223 ++++++++++++++++++ tests/observability/hosting/test_utils.py | 112 +++++++++ 4 files changed, 374 insertions(+) create mode 100644 tests/observability/hosting/__init__.py create mode 100644 tests/observability/hosting/test_populate_baggage.py create mode 100644 tests/observability/hosting/test_populate_invoke_agent_scope.py create mode 100644 tests/observability/hosting/test_utils.py diff --git a/tests/observability/hosting/__init__.py b/tests/observability/hosting/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/tests/observability/hosting/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/observability/hosting/test_populate_baggage.py b/tests/observability/hosting/test_populate_baggage.py new file mode 100644 index 00000000..3cc051d9 --- /dev/null +++ b/tests/observability/hosting/test_populate_baggage.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from unittest.mock import MagicMock + +from microsoft_agents_a365.observability.core.constants import GEN_AI_CALLER_ID_KEY +from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder +from microsoft_agents_a365.observability.hosting.scope_helpers.populate_baggage import populate + + +def test_populate(): + """Test populate populates BaggageBuilder from turn context.""" + # Create a mock turn context with activity + turn_context = MagicMock() + activity = MagicMock() + activity.from_property = MagicMock( + aad_object_id="caller-id", + name="Caller", + agentic_user_id="caller-upn", + tenant_id="tenant-id", + ) + activity.recipient = MagicMock(tenant_id="tenant-id", role="user") + activity.conversation = MagicMock(id="conv-id") + activity.service_url = "https://example.com" + activity.channel_id = "test-channel" + turn_context.activity = activity + + builder = BaggageBuilder() + + result = populate(builder, turn_context) + + assert result == builder + # Verify builder was populated by checking its internal _pairs dict + assert len(builder._pairs) > 0 + # Verify specific expected baggage keys were set + assert GEN_AI_CALLER_ID_KEY in builder._pairs + assert builder._pairs[GEN_AI_CALLER_ID_KEY] == "caller-id" diff --git a/tests/observability/hosting/test_populate_invoke_agent_scope.py b/tests/observability/hosting/test_populate_invoke_agent_scope.py new file mode 100644 index 00000000..f8829b76 --- /dev/null +++ b/tests/observability/hosting/test_populate_invoke_agent_scope.py @@ -0,0 +1,223 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +from unittest.mock import MagicMock + +import pytest +from microsoft_agents_a365.observability.core.agent_details import AgentDetails +from microsoft_agents_a365.observability.core.constants import ( + GEN_AI_AGENT_ID_KEY, + GEN_AI_CALLER_ID_KEY, + GEN_AI_CONVERSATION_ID_KEY, + GEN_AI_EXECUTION_SOURCE_NAME_KEY, + GEN_AI_EXECUTION_TYPE_KEY, + GEN_AI_INPUT_MESSAGES_KEY, + TENANT_ID_KEY, +) +from microsoft_agents_a365.observability.core.invoke_agent_details import InvokeAgentDetails +from microsoft_agents_a365.observability.core.invoke_agent_scope import InvokeAgentScope +from microsoft_agents_a365.observability.core.tenant_details import TenantDetails +from microsoft_agents_a365.observability.hosting.scope_helpers.populate_invoke_agent_scope import ( + populate, + set_caller_tags, + set_conversation_id_tags, + set_execution_type_tags, + set_input_message_tags, + set_source_metadata_tags, + set_target_agent_tags, + set_tenant_id_tags, +) +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider + + +@pytest.fixture(autouse=True) +def enable_telemetry(): + """Enable telemetry and set up tracer provider for all tests in this module.""" + # Set environment variable to enable telemetry + os.environ["ENABLE_OBSERVABILITY"] = "true" + + # Set up a proper tracer provider + provider = TracerProvider() + trace.set_tracer_provider(provider) + + yield + + # Clean up + os.environ.pop("ENABLE_OBSERVABILITY", None) + + +def test_populate(): + """Test populate populates scope from turn context.""" + # Create real InvokeAgentScope with minimal required parameters + invoke_agent_details = InvokeAgentDetails( + details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") + ) + tenant_details = TenantDetails(tenant_id="test-tenant") + scope = InvokeAgentScope(invoke_agent_details, tenant_details) + + # Use mock for TurnContext to avoid dependency on microsoft_agents package + turn_context = MagicMock() + activity = MagicMock() + activity.from_property = MagicMock() + activity.recipient = MagicMock() + activity.conversation = MagicMock() + activity.text = "Test message" + turn_context.activity = activity + + result = populate(scope, turn_context) + + # Verify function completes without error and returns the scope + assert result == scope + + +def test_set_caller_tags(): + """Test set_caller_tags sets caller attributes on scope.""" + # Create real InvokeAgentScope + invoke_agent_details = InvokeAgentDetails( + details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") + ) + tenant_details = TenantDetails(tenant_id="test-tenant") + scope = InvokeAgentScope(invoke_agent_details, tenant_details) + + activity = MagicMock() + activity.from_property = MagicMock( + aad_object_id="caller-id", name="Caller", agentic_user_id="upn", tenant_id="tenant" + ) + + # Verify function completes without error + set_caller_tags(scope, activity) + + # Verify attributes were set on the span (if telemetry is enabled) + if scope._span and hasattr(scope._span, "_attributes"): + assert GEN_AI_CALLER_ID_KEY in scope._span._attributes + assert scope._span._attributes[GEN_AI_CALLER_ID_KEY] == "caller-id" + + +def test_set_execution_type_tags(): + """Test set_execution_type_tags sets execution type on scope.""" + # Create real InvokeAgentScope + invoke_agent_details = InvokeAgentDetails( + details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") + ) + tenant_details = TenantDetails(tenant_id="test-tenant") + scope = InvokeAgentScope(invoke_agent_details, tenant_details) + + activity = MagicMock() + activity.from_property = MagicMock(role="user") + activity.recipient = MagicMock(role="agenticUser") + + # Verify function completes without error + set_execution_type_tags(scope, activity) + + # Verify attributes were set on the span (if telemetry is enabled) + if scope._span and hasattr(scope._span, "_attributes"): + assert GEN_AI_EXECUTION_TYPE_KEY in scope._span._attributes + + +def test_set_target_agent_tags(): + """Test set_target_agent_tags sets target agent attributes on scope.""" + # Create real InvokeAgentScope + invoke_agent_details = InvokeAgentDetails( + details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") + ) + tenant_details = TenantDetails(tenant_id="test-tenant") + scope = InvokeAgentScope(invoke_agent_details, tenant_details) + + activity = MagicMock() + activity.recipient = MagicMock( + agentic_app_id="agent-id", name="Agent", aad_object_id="auid", agentic_user_id="upn" + ) + + # Verify function completes without error + set_target_agent_tags(scope, activity) + + # Verify attributes were set on the span (if telemetry is enabled) + if scope._span and hasattr(scope._span, "_attributes"): + assert GEN_AI_AGENT_ID_KEY in scope._span._attributes + assert scope._span._attributes[GEN_AI_AGENT_ID_KEY] == "agent-id" + + +def test_set_tenant_id_tags(): + """Test set_tenant_id_tags sets tenant ID on scope.""" + # Create real InvokeAgentScope + invoke_agent_details = InvokeAgentDetails( + details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") + ) + tenant_details = TenantDetails(tenant_id="test-tenant") + scope = InvokeAgentScope(invoke_agent_details, tenant_details) + + activity = MagicMock() + activity.recipient = MagicMock(tenant_id="tenant-123") + + # Verify function completes without error + set_tenant_id_tags(scope, activity) + + # Verify attributes were set on the span (if telemetry is enabled) + if scope._span and hasattr(scope._span, "_attributes"): + assert TENANT_ID_KEY in scope._span._attributes + assert scope._span._attributes[TENANT_ID_KEY] == "tenant-123" + + +def test_set_source_metadata_tags(): + """Test set_source_metadata_tags sets source metadata on scope.""" + # Create real InvokeAgentScope + invoke_agent_details = InvokeAgentDetails( + details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") + ) + tenant_details = TenantDetails(tenant_id="test-tenant") + scope = InvokeAgentScope(invoke_agent_details, tenant_details) + + activity = MagicMock() + activity.channel_id = "test-channel" + + # Verify function completes without error + set_source_metadata_tags(scope, activity) + + # Verify attributes were set on the span (if telemetry is enabled) + if scope._span and hasattr(scope._span, "_attributes"): + assert GEN_AI_EXECUTION_SOURCE_NAME_KEY in scope._span._attributes + assert scope._span._attributes[GEN_AI_EXECUTION_SOURCE_NAME_KEY] == "test-channel" + + +def test_set_conversation_id_tags(): + """Test set_conversation_id_tags sets conversation attributes on scope.""" + # Create real InvokeAgentScope + invoke_agent_details = InvokeAgentDetails( + details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") + ) + tenant_details = TenantDetails(tenant_id="test-tenant") + scope = InvokeAgentScope(invoke_agent_details, tenant_details) + + activity = MagicMock() + activity.conversation = MagicMock(id="conv-123") + activity.service_url = "https://example.com" + + # Verify function completes without error + set_conversation_id_tags(scope, activity) + + # Verify attributes were set on the span (if telemetry is enabled) + if scope._span and hasattr(scope._span, "_attributes"): + assert GEN_AI_CONVERSATION_ID_KEY in scope._span._attributes + assert scope._span._attributes[GEN_AI_CONVERSATION_ID_KEY] == "conv-123" + + +def test_set_input_message_tags(): + """Test set_input_message_tags sets input message on scope.""" + # Create real InvokeAgentScope + invoke_agent_details = InvokeAgentDetails( + details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") + ) + tenant_details = TenantDetails(tenant_id="test-tenant") + scope = InvokeAgentScope(invoke_agent_details, tenant_details) + + activity = MagicMock() + activity.text = "Test input message" + + # Verify function completes without error + set_input_message_tags(scope, activity) + + # Verify attributes were set on the span (if telemetry is enabled) + if scope._span and hasattr(scope._span, "_attributes"): + assert GEN_AI_INPUT_MESSAGES_KEY in scope._span._attributes diff --git a/tests/observability/hosting/test_utils.py b/tests/observability/hosting/test_utils.py new file mode 100644 index 00000000..eee20a79 --- /dev/null +++ b/tests/observability/hosting/test_utils.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from microsoft_agents.activity import Activity, ChannelAccount, ConversationAccount +from microsoft_agents_a365.observability.core.constants import ( + GEN_AI_AGENT_AUID_KEY, + GEN_AI_AGENT_DESCRIPTION_KEY, + GEN_AI_AGENT_ID_KEY, + GEN_AI_AGENT_NAME_KEY, + GEN_AI_AGENT_UPN_KEY, + GEN_AI_CALLER_ID_KEY, + GEN_AI_CALLER_NAME_KEY, + GEN_AI_CALLER_TENANT_ID_KEY, + GEN_AI_CALLER_UPN_KEY, + GEN_AI_CONVERSATION_ID_KEY, + GEN_AI_CONVERSATION_ITEM_LINK_KEY, + GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, + GEN_AI_EXECUTION_SOURCE_NAME_KEY, + GEN_AI_EXECUTION_TYPE_KEY, + TENANT_ID_KEY, +) +from microsoft_agents_a365.observability.core.execution_type import ExecutionType +from microsoft_agents_a365.observability.hosting.scope_helpers.utils import ( + get_caller_pairs, + get_conversation_pairs, + get_execution_type_pair, + get_source_metadata_pairs, + get_target_agent_pairs, + get_tenant_id_pair, +) + + +def test_get_caller_pairs(): + """Test get_caller_pairs extracts caller information from activity.""" + from_account = ChannelAccount( + aad_object_id="caller-aad-id", + name="Test Caller", + agentic_user_id="caller-upn", + tenant_id="caller-tenant-id", + ) + activity = Activity(type="message", from_property=from_account) + + result = list(get_caller_pairs(activity)) + + assert (GEN_AI_CALLER_ID_KEY, "caller-aad-id") in result + assert (GEN_AI_CALLER_NAME_KEY, "Test Caller") in result + assert (GEN_AI_CALLER_UPN_KEY, "caller-upn") in result + assert (GEN_AI_CALLER_TENANT_ID_KEY, "caller-tenant-id") in result + + +def test_get_execution_type_pair(): + """Test get_execution_type_pair determines execution type correctly.""" + from_account = ChannelAccount(role="agenticUser") + recipient = ChannelAccount(role="agenticUser") + activity = Activity(type="message", from_property=from_account, recipient=recipient) + + result = list(get_execution_type_pair(activity)) + + assert (GEN_AI_EXECUTION_TYPE_KEY, ExecutionType.AGENT_TO_AGENT.value) in result + + +def test_get_target_agent_pairs(): + """Test get_target_agent_pairs extracts target agent information.""" + recipient = ChannelAccount( + agentic_app_id="agent-app-id", + name="Test Agent", + aad_object_id="agent-auid", + agentic_user_id="agent-upn", + role="Assistant", + ) + activity = Activity(type="message", recipient=recipient) + + result = list(get_target_agent_pairs(activity)) + + assert (GEN_AI_AGENT_ID_KEY, "agent-app-id") in result + assert (GEN_AI_AGENT_NAME_KEY, "Test Agent") in result + assert (GEN_AI_AGENT_AUID_KEY, "agent-auid") in result + assert (GEN_AI_AGENT_UPN_KEY, "agent-upn") in result + assert (GEN_AI_AGENT_DESCRIPTION_KEY, "Assistant") in result + + +def test_get_tenant_id_pair(): + """Test get_tenant_id_pair extracts tenant ID from recipient.""" + recipient = ChannelAccount(tenant_id="test-tenant-id") + activity = Activity(type="message", recipient=recipient) + + result = list(get_tenant_id_pair(activity)) + + assert (TENANT_ID_KEY, "test-tenant-id") in result + + +def test_get_source_metadata_pairs(): + """Test get_source_metadata_pairs extracts channel metadata.""" + activity = Activity(type="message", channel_id="test-channel") + + result = list(get_source_metadata_pairs(activity)) + + assert (GEN_AI_EXECUTION_SOURCE_NAME_KEY, "test-channel") in result + assert (GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, None) in result + + +def test_get_conversation_pairs(): + """Test get_conversation_pairs extracts conversation information.""" + conversation = ConversationAccount(id="conversation-123") + activity = Activity( + type="message", conversation=conversation, service_url="https://example.com" + ) + + result = list(get_conversation_pairs(activity)) + + assert (GEN_AI_CONVERSATION_ID_KEY, "conversation-123") in result + assert (GEN_AI_CONVERSATION_ITEM_LINK_KEY, "https://example.com") in result From 97612e7f6b950b53c048d0e72e355b35d50494ab Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 15 Dec 2025 21:24:00 +0530 Subject: [PATCH 5/9] add token cache --- .../hosting/token_cache_helpers/__init__.py | 7 + .../token_cache_helpers/agent_token_cache.py | 137 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/__init__.py create mode 100644 libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/agent_token_cache.py diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/__init__.py new file mode 100644 index 00000000..23daf0a3 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Token cache helpers for observability.""" + +from .agent_token_cache import AgenticTokenCache, AgenticTokenStruct + +__all__ = ["AgenticTokenCache", "AgenticTokenStruct"] diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/agent_token_cache.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/agent_token_cache.py new file mode 100644 index 00000000..00f6f9d4 --- /dev/null +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/agent_token_cache.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Token cache for observability tokens per (agentId, tenantId). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from threading import Lock + +from microsoft_agents.hosting.core.app.oauth.authorization import Authorization +from microsoft_agents.hosting.core.turn_context import TurnContext + +logger = logging.getLogger(__name__) + + +@dataclass +class AgenticTokenStruct: + """Structure containing the token generation components.""" + + authorization: Authorization + """The user authorization object for token exchange.""" + + turn_context: TurnContext + """The turn context for the current conversation.""" + + auth_handler_name: str | None = "AGENTIC" + """The name of the authentication handler.""" + + +class AgenticTokenCache: + """ + Caches observability tokens per (agentId, tenantId) using the provided + UserAuthorization and TurnContext. + """ + + @dataclass + class _Entry: + """Internal entry structure for cache storage.""" + + agentic_token_struct: AgenticTokenStruct + """The token generation structure.""" + + scopes: list[str] + """The observability scopes for token requests.""" + + def __init__(self) -> None: + """Initialize the token cache.""" + self._map: dict[str, AgenticTokenCache._Entry] = {} + self._lock = Lock() + + def register_observability( + self, + agent_id: str, + tenant_id: str, + token_generator: AgenticTokenStruct, + observability_scopes: list[str], + ) -> None: + """ + Register observability for the specified agent and tenant. + + Args: + agent_id: The agent identifier. + tenant_id: The tenant identifier. + token_generator: The token generator structure. + observability_scopes: The observability scopes. + + Raises: + ValueError: If agent_id or tenant_id is empty or None. + TypeError: If token_generator is None. + """ + if not agent_id or not agent_id.strip(): + raise ValueError("agent_id cannot be None or whitespace") + + if not tenant_id or not tenant_id.strip(): + raise ValueError("tenant_id cannot be None or whitespace") + + if token_generator is None: + raise TypeError("token_generator cannot be None") + + key = f"{agent_id}:{tenant_id}" + + # First registration wins; subsequent calls ignored (idempotent) + with self._lock: + if key not in self._map: + self._map[key] = AgenticTokenCache._Entry( + agentic_token_struct=token_generator, scopes=observability_scopes + ) + logger.debug(f"Registered observability for {key}") + else: + logger.debug(f"Observability already registered for {key}, ignoring") + + async def get_observability_token(self, agent_id: str, tenant_id: str) -> str | None: + """ + Get the observability token for the specified agent and tenant. + + Args: + agent_id: The agent identifier. + tenant_id: The tenant identifier. + + Returns: + The observability token if available; otherwise, None. + """ + key = f"{agent_id}:{tenant_id}" + + logger.debug(f"Cache lookup for {key}") + + with self._lock: + entry = self._map.get(key) + + if entry is None: + logger.debug(f"Cache miss for {key}") + return None + + logger.debug(f"Cache hit for {key}, exchanging token") + + try: + authorization = entry.agentic_token_struct.authorization + turn_context = entry.agentic_token_struct.turn_context + auth_handler_id = entry.agentic_token_struct.auth_handler_name + + # Exchange the turn token for an observability token + token = await authorization.exchange_token( + context=turn_context, + scopes=entry.scopes, + auth_handler_id=auth_handler_id, + ) + + logger.info(f"Successfully exchanged token for {key}") + return token + except Exception as e: + # Return None if token generation fails + logger.error(f"Token exchange failed for {key}: {type(e).__name__}") + return None From e22b8789825dd13f18946f4bbb28309e4274f7ae Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 15 Dec 2025 21:37:22 +0530 Subject: [PATCH 6/9] add tests --- .../test_populate_baggage.py | 0 .../test_populate_invoke_agent_scope.py | 0 .../hosting/{ => scope_helpers}/test_utils.py | 0 .../hosting/token_cache_helpers/__init__.py | 2 + .../test_agent_token_cache.py | 119 ++++++++++++++++++ 5 files changed, 121 insertions(+) rename tests/observability/hosting/{ => scope_helpers}/test_populate_baggage.py (100%) rename tests/observability/hosting/{ => scope_helpers}/test_populate_invoke_agent_scope.py (100%) rename tests/observability/hosting/{ => scope_helpers}/test_utils.py (100%) create mode 100644 tests/observability/hosting/token_cache_helpers/__init__.py create mode 100644 tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py diff --git a/tests/observability/hosting/test_populate_baggage.py b/tests/observability/hosting/scope_helpers/test_populate_baggage.py similarity index 100% rename from tests/observability/hosting/test_populate_baggage.py rename to tests/observability/hosting/scope_helpers/test_populate_baggage.py diff --git a/tests/observability/hosting/test_populate_invoke_agent_scope.py b/tests/observability/hosting/scope_helpers/test_populate_invoke_agent_scope.py similarity index 100% rename from tests/observability/hosting/test_populate_invoke_agent_scope.py rename to tests/observability/hosting/scope_helpers/test_populate_invoke_agent_scope.py diff --git a/tests/observability/hosting/test_utils.py b/tests/observability/hosting/scope_helpers/test_utils.py similarity index 100% rename from tests/observability/hosting/test_utils.py rename to tests/observability/hosting/scope_helpers/test_utils.py diff --git a/tests/observability/hosting/token_cache_helpers/__init__.py b/tests/observability/hosting/token_cache_helpers/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/tests/observability/hosting/token_cache_helpers/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py b/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py new file mode 100644 index 00000000..eb95a122 --- /dev/null +++ b/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for AgenticTokenCache and AgenticTokenStruct.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from microsoft_agents.hosting.core.app.oauth.authorization import Authorization +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents_a365.observability.hosting.token_cache_helpers import ( + AgenticTokenCache, + AgenticTokenStruct, +) + + +@pytest.fixture +def mock_authorization(): + """Create a mock Authorization instance.""" + auth = MagicMock(spec=Authorization) + auth.exchange_token = AsyncMock() + return auth + + +@pytest.fixture +def mock_turn_context(): + """Create a mock TurnContext instance.""" + return MagicMock(spec=TurnContext) + + +@pytest.fixture +def token_cache(): + """Create a fresh AgenticTokenCache instance.""" + return AgenticTokenCache() + + +@pytest.mark.asyncio +async def test_register_and_retrieve_token_success( + token_cache, mock_authorization, mock_turn_context +): + """Test complete flow: create struct, register, and retrieve token successfully.""" + agent_id = "agent-123" + tenant_id = "tenant-456" + expected_token = "mock-token-xyz" + scopes = ["https://example.com/.default"] + + # Setup mock + mock_authorization.exchange_token.return_value = expected_token + + # Create struct with default auth handler + token_struct = AgenticTokenStruct( + authorization=mock_authorization, + turn_context=mock_turn_context, + ) + assert token_struct.auth_handler_name == "AGENTIC" + + # Register + token_cache.register_observability( + agent_id=agent_id, + tenant_id=tenant_id, + token_generator=token_struct, + observability_scopes=scopes, + ) + + # Retrieve token + token = await token_cache.get_observability_token(agent_id, tenant_id) + + assert token == expected_token + mock_authorization.exchange_token.assert_called_once_with( + context=mock_turn_context, + scopes=scopes, + auth_handler_id="AGENTIC", + ) + + +@pytest.mark.parametrize( + "agent_id,tenant_id,token_generator,error_type,error_match", + [ + ("", "tenant-456", "valid", ValueError, "agent_id cannot be None or whitespace"), + ("agent-123", None, "valid", ValueError, "tenant_id cannot be None or whitespace"), + ("agent-123", "tenant-456", None, TypeError, "token_generator cannot be None"), + ], +) +@pytest.mark.asyncio +def test_thread_safety(token_cache, mock_authorization, mock_turn_context): + """Test that cache is thread-safe with concurrent registrations.""" + import threading + + agent_id = "agent-123" + tenant_id = "tenant-456" + results = [] + + def register_token(scope_suffix): + try: + struct = AgenticTokenStruct( + authorization=mock_authorization, + turn_context=mock_turn_context, + ) + token_cache.register_observability( + agent_id=agent_id, + tenant_id=tenant_id, + token_generator=struct, + observability_scopes=[f"scope-{scope_suffix}"], + ) + results.append(scope_suffix) + except Exception as e: + results.append(f"error: {e}") + + # Create 10 concurrent registrations + threads = [threading.Thread(target=register_token, args=(i,)) for i in range(10)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + # All registrations should succeed + assert len(results) == 10 + # Only one entry should exist (idempotent) + assert f"{agent_id}:{tenant_id}" in token_cache._map From 37a94df9e378678719c38ba180b47dd17303e261 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 15 Dec 2025 21:57:17 +0530 Subject: [PATCH 7/9] remove unused tests --- .../core/test_baggage_builder.py | 46 ---- .../core/test_turn_context_baggage.py | 211 ------------------ .../test_agent_token_cache.py | 39 +++- 3 files changed, 27 insertions(+), 269 deletions(-) delete mode 100644 tests/observability/core/test_turn_context_baggage.py diff --git a/tests/observability/core/test_baggage_builder.py b/tests/observability/core/test_baggage_builder.py index 196a2414..e6b398b5 100644 --- a/tests/observability/core/test_baggage_builder.py +++ b/tests/observability/core/test_baggage_builder.py @@ -247,52 +247,6 @@ def test_set_pairs_accepts_dict_and_iterable(self): self.assertIsNone(baggage_contents.get(GEN_AI_CALLER_ID_KEY)) self.assertIsNone(baggage_contents.get(HIRING_MANAGER_ID_KEY)) - def test_from_turn_context_delegates_and_merges(self): - """from_turn_context should delegate to baggage_turn_context.from_turn_context and merge returned pairs.""" - # Import the module to monkeypatch the symbol that BaggageBuilder closed over - from microsoft_agents_a365.observability.core.middleware import ( - baggage_builder as tempBaggageBuilder, - ) - - original_fn = tempBaggageBuilder.from_turn_context - - # Fake turn_context -> returns a mix of valid/ignored values - def fake_from_turn_context(turn_ctx: any): - self.assertEqual(turn_ctx, {"k": "v"}) # ensure pass-through of arg - return { - TENANT_ID_KEY: "tenant-ctx", - GEN_AI_AGENT_ID_KEY: "agent-ctx", - CORRELATION_ID_KEY: " ", # will be ignored - GEN_AI_AGENT_UPN_KEY: None, # will be ignored - OPERATION_SOURCE_KEY: OperationSource.SDK.value, - } - - try: - tempBaggageBuilder.from_turn_context = fake_from_turn_context - - with ( - BaggageBuilder() - .tenant_id("tenant-pre") # will be overridden if same key is set later - .from_turn_context({"k": "v"}) # merges keys from fake function - .agent_auid("auid-pre") # ensure pre-existing values remain - .build() - ): - baggage_contents = baggage.get_all() - # Values from turn_context - self.assertEqual(baggage_contents.get(TENANT_ID_KEY), "tenant-ctx") - self.assertEqual(baggage_contents.get(GEN_AI_AGENT_ID_KEY), "agent-ctx") - self.assertEqual( - baggage_contents.get(OPERATION_SOURCE_KEY), OperationSource.SDK.value - ) - # Pre-existing (non-overlapping) still present - self.assertEqual(baggage_contents.get(GEN_AI_AGENT_AUID_KEY), "auid-pre") - # Ignored values should not be present - self.assertIsNone(baggage_contents.get(CORRELATION_ID_KEY)) - self.assertIsNone(baggage_contents.get(GEN_AI_AGENT_UPN_KEY)) - finally: - # Restore original - tempBaggageBuilder.from_turn_context = original_fn - def test_source_metadata_name_method(self): """Test deprecated source_metadata_name method - should delegate to channel_name.""" # Should exist and be callable diff --git a/tests/observability/core/test_turn_context_baggage.py b/tests/observability/core/test_turn_context_baggage.py deleted file mode 100644 index 1fef1dce..00000000 --- a/tests/observability/core/test_turn_context_baggage.py +++ /dev/null @@ -1,211 +0,0 @@ -import unittest - -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_AGENT_AUID_KEY, - GEN_AI_AGENT_DESCRIPTION_KEY, - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_NAME_KEY, - GEN_AI_AGENT_UPN_KEY, - GEN_AI_CALLER_ID_KEY, - GEN_AI_CALLER_NAME_KEY, - GEN_AI_CALLER_TENANT_ID_KEY, - GEN_AI_CALLER_UPN_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_CONVERSATION_ITEM_LINK_KEY, - GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, - GEN_AI_EXECUTION_SOURCE_NAME_KEY, - GEN_AI_EXECUTION_TYPE_KEY, - TENANT_ID_KEY, -) -from microsoft_agents_a365.observability.core.execution_type import ExecutionType -from microsoft_agents_a365.observability.core.middleware import turn_context_baggage as tcb - - -class FakeEntity: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - -class FakeActivity: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - -class FakeTurnContext: - def __init__(self, activity): - self.activity = activity - - -class TestTurnContextBaggage(unittest.TestCase): - def test_iter_caller_pairs_basic_and_upn_fallback(self): - activity = FakeActivity(**{ - "from": FakeEntity(id="caller-123", name="Alice", tenant_id="tenant-xyz"), - }) - pairs = dict(tcb._iter_caller_pairs(activity)) - self.assertEqual(pairs[GEN_AI_CALLER_ID_KEY], "caller-123") - self.assertEqual(pairs[GEN_AI_CALLER_NAME_KEY], "Alice") - # upn missing, should fallback to name - self.assertEqual(pairs[GEN_AI_CALLER_UPN_KEY], "Alice") - # user id may be absent -> None (not included if None) - self.assertIn(GEN_AI_CALLER_TENANT_ID_KEY, pairs) - self.assertEqual(pairs[GEN_AI_CALLER_TENANT_ID_KEY], "tenant-xyz") - - def test_iter_execution_type_agent_to_agent(self): - activity = FakeActivity(**{ - "from": FakeEntity(role="agenticUser", agentic_user_id="u1"), - "recipient": FakeEntity(role="agenticUser", agentic_user_id="u2"), - }) - pairs = dict(tcb._iter_execution_type_pair(activity)) - self.assertEqual(pairs[GEN_AI_EXECUTION_TYPE_KEY], ExecutionType.AGENT_TO_AGENT.value) - - def test_iter_execution_type_human_to_agent(self): - activity = FakeActivity(**{ - "from": FakeEntity(role="user"), - "recipient": FakeEntity(role="agenticUser", agentic_user_id="u2"), - }) - pairs = dict(tcb._iter_execution_type_pair(activity)) - self.assertEqual(pairs[GEN_AI_EXECUTION_TYPE_KEY], ExecutionType.HUMAN_TO_AGENT.value) - - def test_iter_target_agent_pairs_with_fallbacks(self): - activity = FakeActivity(**{ - "recipient": FakeEntity( - agentic_app_id="app-456", - name="MyAgent", - agentic_user_id="auid-789", - role="agenticUser", - ) - }) - pairs = dict(tcb._iter_target_agent_pairs(activity)) - self.assertEqual(pairs[GEN_AI_AGENT_ID_KEY], "app-456") - self.assertEqual(pairs[GEN_AI_AGENT_NAME_KEY], "MyAgent") - self.assertEqual(pairs[GEN_AI_AGENT_AUID_KEY], "auid-789") - # upn missing -> fallback to name - self.assertEqual(pairs[GEN_AI_AGENT_UPN_KEY], "MyAgent") - self.assertEqual(pairs[GEN_AI_AGENT_DESCRIPTION_KEY], "agenticUser") - - def test_iter_tenant_id_pair_primary_and_channel_data_fallback(self): - # Case 1: tenant_id present directly - activity_direct = FakeActivity(**{ - "recipient": FakeEntity(tenant_id="t-direct"), - }) - direct = dict(tcb._iter_tenant_id_pair(activity_direct)) - self.assertEqual(direct[TENANT_ID_KEY], "t-direct") - - # Case 2: missing recipient tenant_id but present in channel_data - activity_fallback = FakeActivity(**{ - "recipient": FakeEntity(), # no tenant_id - "channel_data": { - "tenant": {"id": "t-channel"}, - }, - }) - fallback = dict(tcb._iter_tenant_id_pair(activity_fallback)) - self.assertEqual(fallback[TENANT_ID_KEY], "t-channel") - - # Case 3: no tenant anywhere - activity_none = FakeActivity(**{ - "recipient": FakeEntity(), - }) - none_val = dict(tcb._iter_tenant_id_pair(activity_none)) - self.assertIsNone(none_val[TENANT_ID_KEY]) - - def test_iter_source_metadata_pairs(self): - activity = FakeActivity(**{ - "channel_id": "msteams", - "type": "message", - }) - pairs = dict(tcb._iter_source_metadata_pairs(activity)) - self.assertEqual(pairs[GEN_AI_EXECUTION_SOURCE_NAME_KEY], "msteams") - self.assertIsNone(pairs.get(GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY)) - - def test_iter_conversation_pairs_wpxcomment(self): - activity = FakeActivity(**{ - "channel_id": "agents", - "entities": [ - FakeEntity( - type="wpxcomment", - documentId="doc-100", - parentCommentId="parent-200", - ) - ], - "service_url": "https://service/link", - }) - pairs = dict(tcb._iter_conversation_pairs(activity)) - self.assertIsNone(pairs.get(GEN_AI_CONVERSATION_ID_KEY)) - self.assertEqual(pairs[GEN_AI_CONVERSATION_ITEM_LINK_KEY], "https://service/link") - - def test_iter_conversation_pairs_email_notification(self): - activity = FakeActivity(**{ - "channel_id": "agents", - "entities": [ - FakeEntity( - type="emailNotification", - conversationId="email-conv-123", - ) - ], - "service_url": "http://service/url", - }) - pairs = dict(tcb._iter_conversation_pairs(activity)) - self.assertIsNone(pairs.get(GEN_AI_CONVERSATION_ID_KEY)) - self.assertEqual(pairs[GEN_AI_CONVERSATION_ITEM_LINK_KEY], "http://service/url") - - def test_iter_conversation_pairs_fallback_conversation(self): - activity = FakeActivity(**{ - "channel_id": "msteams", - "conversation": FakeEntity(id="conv-777"), - "service_url": "svc", - }) - pairs = dict(tcb._iter_conversation_pairs(activity)) - self.assertEqual(pairs[GEN_AI_CONVERSATION_ID_KEY], "conv-777") - self.assertEqual(pairs[GEN_AI_CONVERSATION_ITEM_LINK_KEY], "svc") - - def test_from_turn_context_aggregates_all(self): - activity = FakeActivity(**{ - "from": FakeEntity(id="caller", name="CallerName"), - "recipient": FakeEntity( - agentic_app_id="app-id", - name="AgentName", - agentic_user_id="auid-1", - tenant_id="t-1", - role="agenticUser", - ), - "channel_id": "agents", - "type": "message", - "entities": [ - FakeEntity( - type="emailNotification", - conversationId="email-conv-123", - ) - ], - "service_url": "svc-url", - }) - ctx = FakeTurnContext(activity) - result = tcb.from_turn_context(ctx) - - # Caller fields - self.assertEqual(result[GEN_AI_CALLER_ID_KEY], "caller") - self.assertEqual(result[GEN_AI_CALLER_NAME_KEY], "CallerName") - # Agent fields - self.assertEqual(result[GEN_AI_AGENT_ID_KEY], "app-id") - self.assertEqual(result[GEN_AI_AGENT_NAME_KEY], "AgentName") - self.assertEqual(result[GEN_AI_AGENT_AUID_KEY], "auid-1") - # Tenant - self.assertEqual(result[TENANT_ID_KEY], "t-1") - # Execution type (agent-to-agent) - self.assertEqual(result[GEN_AI_EXECUTION_TYPE_KEY], ExecutionType.HUMAN_TO_AGENT.value) - # Conversation - self.assertIsNone(result.get(GEN_AI_CONVERSATION_ID_KEY)) - self.assertEqual(result[GEN_AI_CONVERSATION_ITEM_LINK_KEY], "svc-url") - # Source metadata - self.assertEqual(result[GEN_AI_EXECUTION_SOURCE_NAME_KEY], "agents") - self.assertIsNone(result.get(GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY)) - - def test_from_turn_context_missing_activity(self): - ctx = FakeTurnContext(activity=None) - result = tcb.from_turn_context(ctx) - self.assertEqual(result, {}, "Expected empty dict when activity missing") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py b/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py index eb95a122..eb505ce3 100644 --- a/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py +++ b/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py @@ -44,17 +44,14 @@ async def test_register_and_retrieve_token_success( expected_token = "mock-token-xyz" scopes = ["https://example.com/.default"] - # Setup mock mock_authorization.exchange_token.return_value = expected_token - # Create struct with default auth handler token_struct = AgenticTokenStruct( authorization=mock_authorization, turn_context=mock_turn_context, ) assert token_struct.auth_handler_name == "AGENTIC" - # Register token_cache.register_observability( agent_id=agent_id, tenant_id=tenant_id, @@ -62,26 +59,44 @@ async def test_register_and_retrieve_token_success( observability_scopes=scopes, ) - # Retrieve token token = await token_cache.get_observability_token(agent_id, tenant_id) - assert token == expected_token - mock_authorization.exchange_token.assert_called_once_with( - context=mock_turn_context, - scopes=scopes, - auth_handler_id="AGENTIC", - ) @pytest.mark.parametrize( "agent_id,tenant_id,token_generator,error_type,error_match", [ ("", "tenant-456", "valid", ValueError, "agent_id cannot be None or whitespace"), - ("agent-123", None, "valid", ValueError, "tenant_id cannot be None or whitespace"), ("agent-123", "tenant-456", None, TypeError, "token_generator cannot be None"), ], ) -@pytest.mark.asyncio +def test_register_observability_validation( + token_cache, + mock_authorization, + mock_turn_context, + agent_id, + tenant_id, + token_generator, + error_type, + error_match, +): + """Test that registration validates inputs and raises appropriate errors.""" + struct = None + if token_generator == "valid": + struct = AgenticTokenStruct( + authorization=mock_authorization, + turn_context=mock_turn_context, + ) + + with pytest.raises(error_type, match=error_match): + token_cache.register_observability( + agent_id=agent_id, + tenant_id=tenant_id, + token_generator=struct, + observability_scopes=["scope"], + ) + + def test_thread_safety(token_cache, mock_authorization, mock_turn_context): """Test that cache is thread-safe with concurrent registrations.""" import threading From 1dc7c2e435a959682a4cd5a4aab7f162a236e1b9 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 15 Dec 2025 22:03:41 +0530 Subject: [PATCH 8/9] rename test file due to conflict --- .../scope_helpers/{test_utils.py => test_scope_helper_utils.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/observability/hosting/scope_helpers/{test_utils.py => test_scope_helper_utils.py} (100%) diff --git a/tests/observability/hosting/scope_helpers/test_utils.py b/tests/observability/hosting/scope_helpers/test_scope_helper_utils.py similarity index 100% rename from tests/observability/hosting/scope_helpers/test_utils.py rename to tests/observability/hosting/scope_helpers/test_scope_helper_utils.py From 0a2a152c15a4901d4f9b14170b9c5fe5a7da2f63 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 15 Dec 2025 23:28:17 +0530 Subject: [PATCH 9/9] address pr comments --- .../populate_invoke_agent_scope.py | 37 +--- .../scope_helpers/test_populate_baggage.py | 30 +-- .../test_populate_invoke_agent_scope.py | 193 +++--------------- 3 files changed, 50 insertions(+), 210 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py index 4dba6286..f7b4f517 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py @@ -32,47 +32,14 @@ def populate(scope: InvokeAgentScope, turn_context: TurnContext) -> InvokeAgentS activity = turn_context.activity - set_caller_tags(scope, activity) - set_execution_type_tags(scope, activity) - set_target_agent_tags(scope, activity) - set_tenant_id_tags(scope, activity) - set_source_metadata_tags(scope, activity) - set_conversation_id_tags(scope, activity) - set_input_message_tags(scope, activity) - return scope - - -def set_caller_tags(scope: InvokeAgentScope, activity) -> None: - """Sets the caller-related attribute values from the Activity.""" scope.record_attributes(get_caller_pairs(activity)) - - -def set_execution_type_tags(scope: InvokeAgentScope, activity) -> None: - """Sets the execution type tag based on caller and recipient agentic status.""" scope.record_attributes(get_execution_type_pair(activity)) - - -def set_target_agent_tags(scope: InvokeAgentScope, activity) -> None: - """Sets the target agent-related tags from the Activity.""" scope.record_attributes(get_target_agent_pairs(activity)) - - -def set_tenant_id_tags(scope: InvokeAgentScope, activity) -> None: - """Sets the tenant ID tag, extracting from ChannelData if necessary.""" scope.record_attributes(get_tenant_id_pair(activity)) - - -def set_source_metadata_tags(scope: InvokeAgentScope, activity) -> None: - """Sets the source metadata tags from the Activity.""" scope.record_attributes(get_source_metadata_pairs(activity)) - - -def set_conversation_id_tags(scope: InvokeAgentScope, activity) -> None: - """Sets the conversation ID and item link tags from the Activity.""" scope.record_attributes(get_conversation_pairs(activity)) - -def set_input_message_tags(scope: InvokeAgentScope, activity) -> None: - """Sets the input message tag from the Activity.""" if activity.text: scope.record_input_messages([activity.text]) + + return scope diff --git a/tests/observability/hosting/scope_helpers/test_populate_baggage.py b/tests/observability/hosting/scope_helpers/test_populate_baggage.py index 3cc051d9..b6cc7492 100644 --- a/tests/observability/hosting/scope_helpers/test_populate_baggage.py +++ b/tests/observability/hosting/scope_helpers/test_populate_baggage.py @@ -3,6 +3,8 @@ from unittest.mock import MagicMock +from microsoft_agents.activity import Activity, ChannelAccount, ConversationAccount +from microsoft_agents.hosting.core import TurnContext from microsoft_agents_a365.observability.core.constants import GEN_AI_CALLER_ID_KEY from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder from microsoft_agents_a365.observability.hosting.scope_helpers.populate_baggage import populate @@ -10,20 +12,22 @@ def test_populate(): """Test populate populates BaggageBuilder from turn context.""" - # Create a mock turn context with activity - turn_context = MagicMock() - activity = MagicMock() - activity.from_property = MagicMock( - aad_object_id="caller-id", - name="Caller", - agentic_user_id="caller-upn", - tenant_id="tenant-id", + # Create a real activity and turn context + activity = Activity( + type="message", + from_property=ChannelAccount( + aad_object_id="caller-id", + name="Caller", + agentic_user_id="caller-upn", + tenant_id="tenant-id", + ), + recipient=ChannelAccount(tenant_id="tenant-id", role="user"), + conversation=ConversationAccount(id="conv-id"), + service_url="https://example.com", + channel_id="test-channel", ) - activity.recipient = MagicMock(tenant_id="tenant-id", role="user") - activity.conversation = MagicMock(id="conv-id") - activity.service_url = "https://example.com" - activity.channel_id = "test-channel" - turn_context.activity = activity + adapter = MagicMock() + turn_context = TurnContext(adapter, activity) builder = BaggageBuilder() diff --git a/tests/observability/hosting/scope_helpers/test_populate_invoke_agent_scope.py b/tests/observability/hosting/scope_helpers/test_populate_invoke_agent_scope.py index f8829b76..6aa06b40 100644 --- a/tests/observability/hosting/scope_helpers/test_populate_invoke_agent_scope.py +++ b/tests/observability/hosting/scope_helpers/test_populate_invoke_agent_scope.py @@ -5,28 +5,21 @@ from unittest.mock import MagicMock import pytest +from microsoft_agents.activity import Activity, ChannelAccount, ConversationAccount +from microsoft_agents.hosting.core import TurnContext from microsoft_agents_a365.observability.core.agent_details import AgentDetails from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_AGENT_ID_KEY, GEN_AI_CALLER_ID_KEY, GEN_AI_CONVERSATION_ID_KEY, GEN_AI_EXECUTION_SOURCE_NAME_KEY, GEN_AI_EXECUTION_TYPE_KEY, GEN_AI_INPUT_MESSAGES_KEY, - TENANT_ID_KEY, ) from microsoft_agents_a365.observability.core.invoke_agent_details import InvokeAgentDetails from microsoft_agents_a365.observability.core.invoke_agent_scope import InvokeAgentScope from microsoft_agents_a365.observability.core.tenant_details import TenantDetails from microsoft_agents_a365.observability.hosting.scope_helpers.populate_invoke_agent_scope import ( populate, - set_caller_tags, - set_conversation_id_tags, - set_execution_type_tags, - set_input_message_tags, - set_source_metadata_tags, - set_target_agent_tags, - set_tenant_id_tags, ) from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider @@ -57,167 +50,43 @@ def test_populate(): tenant_details = TenantDetails(tenant_id="test-tenant") scope = InvokeAgentScope(invoke_agent_details, tenant_details) - # Use mock for TurnContext to avoid dependency on microsoft_agents package - turn_context = MagicMock() - activity = MagicMock() - activity.from_property = MagicMock() - activity.recipient = MagicMock() - activity.conversation = MagicMock() - activity.text = "Test message" - turn_context.activity = activity + # Create real Activity and TurnContext + activity = Activity( + type="message", + from_property=ChannelAccount(id="caller-id", aad_object_id="caller-aad-id", name="Caller"), + recipient=ChannelAccount(id="agent-id", agentic_app_id="agent-app-id", name="Agent"), + conversation=ConversationAccount(id="conv-123"), + text="Test message", + channel_id="test-channel", + service_url="https://example.com", + ) + adapter = MagicMock() + turn_context = TurnContext(adapter, activity) result = populate(scope, turn_context) # Verify function completes without error and returns the scope assert result == scope + # Verify attributes were set on the span + assert scope._span is not None + attributes = scope._span._attributes -def test_set_caller_tags(): - """Test set_caller_tags sets caller attributes on scope.""" - # Create real InvokeAgentScope - invoke_agent_details = InvokeAgentDetails( - details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") - ) - tenant_details = TenantDetails(tenant_id="test-tenant") - scope = InvokeAgentScope(invoke_agent_details, tenant_details) - - activity = MagicMock() - activity.from_property = MagicMock( - aad_object_id="caller-id", name="Caller", agentic_user_id="upn", tenant_id="tenant" - ) - - # Verify function completes without error - set_caller_tags(scope, activity) - - # Verify attributes were set on the span (if telemetry is enabled) - if scope._span and hasattr(scope._span, "_attributes"): - assert GEN_AI_CALLER_ID_KEY in scope._span._attributes - assert scope._span._attributes[GEN_AI_CALLER_ID_KEY] == "caller-id" - - -def test_set_execution_type_tags(): - """Test set_execution_type_tags sets execution type on scope.""" - # Create real InvokeAgentScope - invoke_agent_details = InvokeAgentDetails( - details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") - ) - tenant_details = TenantDetails(tenant_id="test-tenant") - scope = InvokeAgentScope(invoke_agent_details, tenant_details) - - activity = MagicMock() - activity.from_property = MagicMock(role="user") - activity.recipient = MagicMock(role="agenticUser") - - # Verify function completes without error - set_execution_type_tags(scope, activity) - - # Verify attributes were set on the span (if telemetry is enabled) - if scope._span and hasattr(scope._span, "_attributes"): - assert GEN_AI_EXECUTION_TYPE_KEY in scope._span._attributes + # Check caller attributes + assert GEN_AI_CALLER_ID_KEY in attributes + assert attributes[GEN_AI_CALLER_ID_KEY] == "caller-aad-id" + # Check execution type + assert GEN_AI_EXECUTION_TYPE_KEY in attributes -def test_set_target_agent_tags(): - """Test set_target_agent_tags sets target agent attributes on scope.""" - # Create real InvokeAgentScope - invoke_agent_details = InvokeAgentDetails( - details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") - ) - tenant_details = TenantDetails(tenant_id="test-tenant") - scope = InvokeAgentScope(invoke_agent_details, tenant_details) - - activity = MagicMock() - activity.recipient = MagicMock( - agentic_app_id="agent-id", name="Agent", aad_object_id="auid", agentic_user_id="upn" - ) - - # Verify function completes without error - set_target_agent_tags(scope, activity) - - # Verify attributes were set on the span (if telemetry is enabled) - if scope._span and hasattr(scope._span, "_attributes"): - assert GEN_AI_AGENT_ID_KEY in scope._span._attributes - assert scope._span._attributes[GEN_AI_AGENT_ID_KEY] == "agent-id" - - -def test_set_tenant_id_tags(): - """Test set_tenant_id_tags sets tenant ID on scope.""" - # Create real InvokeAgentScope - invoke_agent_details = InvokeAgentDetails( - details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") - ) - tenant_details = TenantDetails(tenant_id="test-tenant") - scope = InvokeAgentScope(invoke_agent_details, tenant_details) - - activity = MagicMock() - activity.recipient = MagicMock(tenant_id="tenant-123") - - # Verify function completes without error - set_tenant_id_tags(scope, activity) - - # Verify attributes were set on the span (if telemetry is enabled) - if scope._span and hasattr(scope._span, "_attributes"): - assert TENANT_ID_KEY in scope._span._attributes - assert scope._span._attributes[TENANT_ID_KEY] == "tenant-123" - - -def test_set_source_metadata_tags(): - """Test set_source_metadata_tags sets source metadata on scope.""" - # Create real InvokeAgentScope - invoke_agent_details = InvokeAgentDetails( - details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") - ) - tenant_details = TenantDetails(tenant_id="test-tenant") - scope = InvokeAgentScope(invoke_agent_details, tenant_details) - - activity = MagicMock() - activity.channel_id = "test-channel" - - # Verify function completes without error - set_source_metadata_tags(scope, activity) - - # Verify attributes were set on the span (if telemetry is enabled) - if scope._span and hasattr(scope._span, "_attributes"): - assert GEN_AI_EXECUTION_SOURCE_NAME_KEY in scope._span._attributes - assert scope._span._attributes[GEN_AI_EXECUTION_SOURCE_NAME_KEY] == "test-channel" - - -def test_set_conversation_id_tags(): - """Test set_conversation_id_tags sets conversation attributes on scope.""" - # Create real InvokeAgentScope - invoke_agent_details = InvokeAgentDetails( - details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") - ) - tenant_details = TenantDetails(tenant_id="test-tenant") - scope = InvokeAgentScope(invoke_agent_details, tenant_details) - - activity = MagicMock() - activity.conversation = MagicMock(id="conv-123") - activity.service_url = "https://example.com" - - # Verify function completes without error - set_conversation_id_tags(scope, activity) - - # Verify attributes were set on the span (if telemetry is enabled) - if scope._span and hasattr(scope._span, "_attributes"): - assert GEN_AI_CONVERSATION_ID_KEY in scope._span._attributes - assert scope._span._attributes[GEN_AI_CONVERSATION_ID_KEY] == "conv-123" - - -def test_set_input_message_tags(): - """Test set_input_message_tags sets input message on scope.""" - # Create real InvokeAgentScope - invoke_agent_details = InvokeAgentDetails( - details=AgentDetails(agent_id="test-agent", agent_name="Test Agent") - ) - tenant_details = TenantDetails(tenant_id="test-tenant") - scope = InvokeAgentScope(invoke_agent_details, tenant_details) - - activity = MagicMock() - activity.text = "Test input message" + # Check execution source + assert GEN_AI_EXECUTION_SOURCE_NAME_KEY in attributes + assert attributes[GEN_AI_EXECUTION_SOURCE_NAME_KEY] == "test-channel" - # Verify function completes without error - set_input_message_tags(scope, activity) + # Check conversation ID + assert GEN_AI_CONVERSATION_ID_KEY in attributes + assert attributes[GEN_AI_CONVERSATION_ID_KEY] == "conv-123" - # Verify attributes were set on the span (if telemetry is enabled) - if scope._span and hasattr(scope._span, "_attributes"): - assert GEN_AI_INPUT_MESSAGES_KEY in scope._span._attributes + # Check input messages + assert GEN_AI_INPUT_MESSAGES_KEY in attributes + assert "Test message" in attributes[GEN_AI_INPUT_MESSAGES_KEY]