From 69bf829674a0f96537de8eb56057bc22af79820c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 10 Aug 2026 14:14:41 -0700 Subject: [PATCH 1/8] Parity updates for proactive --- .../hosting/core/app/agent_application.py | 2 +- .../proactive/create_conversation_options.py | 13 ++-- .../hosting/core/app/proactive/proactive.py | 70 ++++++++----------- .../core/app/proactive/proactive_options.py | 5 +- .../hosting/msteams/_http_client.py | 35 ++++++++++ 5 files changed, 73 insertions(+), 52 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index f81ac303..0039eefa 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -80,7 +80,7 @@ class AgentApplication(Agent, Generic[StateT]): _adapter: ChannelServiceAdapter | None = None _adaptive_card: AdaptiveCard _auth: Authorization - _proactive: Proactive | None = None + _proactive: Proactive _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] _route_list: _RouteList[StateT] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py index f144c9e5..17cc201b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py @@ -6,7 +6,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional from microsoft_agents.activity import ConversationParameters from microsoft_agents.hosting.core.authorization import ClaimsIdentity @@ -26,10 +25,10 @@ class CreateConversationOptions: passed to the channel when creating the conversation. :type parameters: :class:`~microsoft_agents.activity.ConversationParameters` :param service_url: Optional override for the channel service URL. - :type service_url: Optional[str] + :type service_url: str | None :param audience: Optional OAuth audience override. When ``None`` the audience is derived from *identity*. - :type audience: Optional[str] + :type audience: str | None :param store_conversation: When ``True`` the newly created conversation is automatically stored via :meth:`~microsoft_agents.hosting.core.app.proactive.proactive.Proactive.store_conversation` @@ -37,11 +36,11 @@ class CreateConversationOptions: :type store_conversation: bool """ - identity: ClaimsIdentity = field(default=None) + identity: ClaimsIdentity channel_id: str = field(default="") - parameters: Optional[ConversationParameters] = field(default=None) - service_url: Optional[str] = field(default=None) - audience: Optional[str] = field(default=None) + parameters: ConversationParameters | None = field(default=None) + service_url: str | None = field(default=None) + audience: str | None = field(default=None) store_conversation: bool = field(default=False) def validate(self) -> None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py index f7f42d04..08bc031f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -6,12 +6,11 @@ from __future__ import annotations import logging -from typing import Awaitable, Callable, Generic, Optional, TypeVar, TYPE_CHECKING +from typing import Awaitable, Callable, Generic, TypeVar, TYPE_CHECKING from microsoft_agents.activity import Activity, ResourceResponse from microsoft_agents.hosting.core.app.state.turn_state import TurnState -from microsoft_agents.hosting.core.storage import Storage from .conversation import Conversation from .create_conversation_options import CreateConversationOptions @@ -66,25 +65,12 @@ async def notify(context, state): def __init__( self, - app: "AgentApplication[StateT]", + app: AgentApplication, options: ProactiveOptions, ) -> None: self._app = app self._options = options - - # ------------------------------------------------------------------ - # Storage helpers - # ------------------------------------------------------------------ - - @property - def _storage(self) -> Storage: - storage = self._options.storage or self._app.options.storage - if not storage: - raise RuntimeError( - "Proactive messaging requires a Storage instance. " - "Configure ProactiveOptions.storage or ApplicationOptions.storage." - ) - return storage + self._storage = self._options.storage @staticmethod def _storage_key(conversation_id: str) -> str: @@ -129,7 +115,7 @@ async def store_conversation( logger.debug("Storing conversation with key: %s", key) await self._storage.write({key: conversation}) - async def get_conversation(self, conversation_id: str) -> Optional[Conversation]: + async def get_conversation(self, conversation_id: str) -> Conversation | None: """ Retrieve a previously stored :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. @@ -165,10 +151,10 @@ async def delete_conversation(self, conversation_id: str) -> None: async def send_activity( self, - adapter: "ChannelServiceAdapter", - conversation_id_or_conversation: "str | Conversation", + adapter: ChannelServiceAdapter, + conversation_id_or_conversation: str | Conversation, activity: Activity, - ) -> Optional[ResourceResponse]: + ) -> ResourceResponse | None: """ Send a single activity into an existing conversation. @@ -195,17 +181,19 @@ async def send_activity( @staticmethod async def _send_activity_impl( - adapter: "ChannelServiceAdapter", + adapter: ChannelServiceAdapter, conversation: Conversation, activity: Activity, - ) -> Optional[ResourceResponse]: - result: Optional[ResourceResponse] = None - captured_exc: Optional[BaseException] = None + ) -> ResourceResponse | None: + """Send an activity into a conversation without loading state or running a handler.""" + + result: ResourceResponse | None = None + captured_exc: BaseException | None = None claims = Conversation.identity_from_claims(conversation.claims) continuation = conversation.conversation_reference.get_continuation_activity() - async def _callback(context: "TurnContext") -> None: + async def _callback(context: TurnContext) -> None: nonlocal result, captured_exc try: result = await context.send_activity(activity) @@ -224,12 +212,12 @@ async def _callback(context: "TurnContext") -> None: async def continue_conversation( self, - adapter: "ChannelServiceAdapter", - conversation_id_or_conversation: "str | Conversation", + adapter: ChannelServiceAdapter, + conversation_id_or_conversation: str | Conversation, handler: RouteHandler, *, - continuation_activity: Optional[Activity] = None, - token_handlers: Optional[list[str]] = None, + continuation_activity: Activity | None = None, + token_handlers: list[str] | None = None, ) -> None: """ Continue an existing conversation by invoking *handler* inside a full @@ -264,14 +252,14 @@ async def continue_conversation( conversation = await self._resolve_conversation(conversation_id_or_conversation) conversation_id = conversation.conversation_reference.conversation.id - captured_exc: Optional[BaseException] = None + captured_exc: BaseException | None = None claims = Conversation.identity_from_claims(conversation.claims) continuation = ( continuation_activity or conversation.conversation_reference.get_continuation_activity() ) - async def _callback(context: "TurnContext") -> None: + async def _callback(context: TurnContext) -> None: nonlocal captured_exc try: await self._on_turn(context, handler, token_handlers) @@ -293,9 +281,9 @@ async def _callback(context: "TurnContext") -> None: async def create_conversation( self, - adapter: "ChannelServiceAdapter", + adapter: ChannelServiceAdapter, options: CreateConversationOptions, - handler: Optional[RouteHandler] = None, + handler: RouteHandler | None = None, ) -> Conversation: """ Create a brand-new conversation with a user and optionally run *handler*. @@ -315,14 +303,14 @@ async def create_conversation( """ options.validate() - new_conversation: Optional[Conversation] = None - captured_exc: Optional[BaseException] = None + new_conversation: Conversation | None = None + captured_exc: BaseException | None = None with spans.ProactiveCreateConversation(options): audience = options.audience or options.identity.get_token_audience() - async def _callback(context: "TurnContext") -> None: + async def _callback(context: TurnContext) -> None: nonlocal new_conversation, captured_exc try: reference = context.activity.get_conversation_reference() @@ -361,9 +349,9 @@ async def _callback(context: "TurnContext") -> None: async def _on_turn( self, - context: "TurnContext", + context: TurnContext, handler: RouteHandler, - token_handlers: Optional[list[str]] = None, + token_handlers: list[str] | None = None, ) -> None: """Run a proactive turn: load state → optional OAuth check → handler → save state.""" state = await self._load_state(context) @@ -388,7 +376,7 @@ async def _on_turn( await handler(context, state) await state.save(context) - async def _load_state(self, context: "TurnContext") -> StateT: + async def _load_state(self, context: TurnContext) -> StateT: if self._app._turn_state_factory: state = self._app._turn_state_factory() else: @@ -398,7 +386,7 @@ async def _load_state(self, context: "TurnContext") -> StateT: async def _resolve_conversation( self, - conversation_id_or_conversation: "str | Conversation", + conversation_id_or_conversation: str | Conversation, ) -> Conversation: if isinstance(conversation_id_or_conversation, str): conversation = await self.get_conversation(conversation_id_or_conversation) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py index 2ff0c0b3..a43fdc40 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py @@ -6,7 +6,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional from microsoft_agents.hosting.core.storage import Storage @@ -17,7 +16,7 @@ class ProactiveOptions: Options for the Proactive messaging subsystem. :param storage: The storage instance used to persist and retrieve conversations. - :type storage: Optional[:class:`microsoft_agents.hosting.core.storage.Storage`] + :type storage: :class:`microsoft_agents.hosting.core.storage.Storage` :param fail_on_unsigned_in_connections: If ``True`` (the default), a :exc:`RuntimeError` is raised when a required OAuth token is not available during a proactive continuation. Set to ``False`` to silently skip the @@ -25,7 +24,7 @@ class ProactiveOptions: :type fail_on_unsigned_in_connections: bool """ - storage: Optional[Storage] = None + storage: Storage """Storage used to persist Conversation objects.""" fail_on_unsigned_in_connections: bool = True diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py new file mode 100644 index 00000000..c3afe1f3 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import ssl +import certifi +import httpx + +from microsoft_teams.common import Client, ClientOptions + + +_ssl_context: ssl.SSLContext | None = None + + +def _get_ssl_context() -> ssl.SSLContext: + global _ssl_context + + if _ssl_context is None: + _ssl_context = ssl.create_default_context(cafile=certifi.where()) + return _ssl_context + + +def _create_http_client(options: ClientOptions | None = None) -> Client: + options = options or ClientOptions() + client = object.__new__(Client) + client._options = options + client._token = options.token + client._interceptors = list(options.interceptors or []) + client.http = httpx.AsyncClient( + base_url=httpx.URL(options.base_url) if options.base_url else "", + headers=options.headers, + timeout=options.timeout, + verify=_get_ssl_context(), + ) + client._update_event_hooks() + return client \ No newline at end of file From da1eb708ec889e4d655799c8c3e43c2c56036235 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 10 Aug 2026 15:09:57 -0700 Subject: [PATCH 2/8] Linking support in SimpleSpanWrapper and Proactive --- .../hosting/core/app/proactive/_utils.py | 19 +++++++ .../core/app/proactive/conversation.py | 50 +++++++++++++++++-- .../hosting/core/app/proactive/proactive.py | 17 ++++--- .../core/app/proactive/telemetry/_utils.py | 37 ++++++++++++++ .../core/app/proactive/telemetry/spans.py | 12 +++-- .../core/telemetry/core/_agents_telemetry.py | 6 ++- .../core/telemetry/core/base_span_wrapper.py | 8 +-- .../telemetry/core/simple_span_wrapper.py | 20 ++++++-- .../hosting/core/telemetry/core/type_defs.py | 4 +- 9 files changed, 147 insertions(+), 26 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/_utils.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/_utils.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/_utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/_utils.py new file mode 100644 index 00000000..42b74c38 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/_utils.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry.core import BaseSpanWrapper +from .conversation import Conversation + +def _link_to_conversation(conversation: Conversation, span: BaseSpanWrapper) -> None: + """Links the given span to the conversation reference of the given conversation, if it exists. + This allows telemetry related to the span to be correlated with telemetry related to the conversation reference, + enabling better observability and debugging of proactive scenarios. + + :param conversation: The conversation whose conversation reference should be linked to the span + :type conversation: Conversation + :param span: The span to link to the conversation reference + :type span: BaseSpanWrapper + """ + if conversation.conversation_reference is not None: + + span.otel_span.add_link(conversation.conversation_reference.to_span_link()) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py index 079136dc..43aea72d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py @@ -1,12 +1,14 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from __future__ import annotations +import functools + from typing import TYPE_CHECKING +from opentelemetry.trace import SpanContext + from microsoft_agents.activity import ConversationReference from microsoft_agents.hosting.core.authorization import ClaimsIdentity from microsoft_agents.hosting.core.storage.store_item import StoreItem @@ -15,6 +17,8 @@ from microsoft_agents.hosting.core.turn_context import TurnContext from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter +from .telemetry._utils import _deserialize_span_context, _dump_span_context + # JWT claim keys that are persisted alongside a ConversationReference. _PERSISTED_CLAIM_KEYS = frozenset({"aud", "azp", "appid", "idtyp", "ver", "iss", "tid"}) @@ -41,7 +45,20 @@ def __init__( self, claims: dict[str, str] | ClaimsIdentity, conversation_reference: ConversationReference, + *, + _span_context: dict | None = None, ) -> None: + """Creates a new :class:`~microsoft_agents.hosting.core.app.proactive.Conversation` instance. + + :param claims: Filtered JWT claims (``aud``, ``azp``, ``appid``, ``idtyp``, + ``ver``, ``iss``, ``tid``). May be a raw ``dict`` or a + :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`. + :type claims: dict[str, str] or ClaimsIdentity + :param conversation_reference: The conversation reference. + :type conversation_reference: :class:`~microsoft_agents.activity.ConversationReference` + :param _span_context: Optional serialized span context for telemetry linking. For internal use only; this is not part of the public API. + :type _span_context: dict or None + """ if isinstance(claims, ClaimsIdentity): self.claims: dict[str, str] = Conversation.claims_from_identity(claims) else: @@ -49,6 +66,29 @@ def __init__( k: v for k, v in claims.items() if k in _PERSISTED_CLAIM_KEYS } self.conversation_reference: ConversationReference = conversation_reference + self._span_context_dict: dict | None = _span_context + + def _set_span_context(self, span_context: SpanContext) -> None: + """Sets the span context for this conversation, serializing it to a dictionary for storage. + + For internal use only; this is not part of the public API. + + :param span_context: The SpanContext to set. + :type span_context: SpanContext + """ + self._span_context_dict = _dump_span_context(span_context) + + def _get_span_context(self) -> SpanContext | None: + """Gets the span context for this conversation, deserializing it from a dictionary. + + For internal use only; this is not part of the public API. + + :return: The SpanContext, or None if not set. + :rtype: SpanContext or None + """ + if self._span_context_dict is None: + return None + return _deserialize_span_context(self._span_context_dict) # ------------------------------------------------------------------ # Factory helpers @@ -135,6 +175,7 @@ def store_item_to_json(self) -> dict: "conversation_reference": self.conversation_reference.model_dump( mode="json", by_alias=True, exclude_unset=True ), + "_span_context": self._span_context_dict, } @staticmethod @@ -145,4 +186,5 @@ def from_json_to_store_item(json_data: dict) -> Conversation: return Conversation( claims=json_data.get("claims", {}), conversation_reference=reference, + _span_context=json_data.get("_span_context", None) ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py index 08bc031f..86151758 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -1,7 +1,5 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from __future__ import annotations @@ -109,7 +107,9 @@ async def store_conversation( with spans.ProactiveStoreConversation( conversation.conversation_reference.conversation.id - ): + ) as span: + if span.otel_span is not None: + conversation._set_span_context(span.otel_span.get_span_context()) conversation.validate() key = self._storage_key(conversation.conversation_reference.conversation.id) logger.debug("Storing conversation with key: %s", key) @@ -176,7 +176,7 @@ async def send_activity( """ conversation = await self._resolve_conversation(conversation_id_or_conversation) conversation_id = conversation.conversation_reference.conversation.id - with spans.ProactiveSendActivity(conversation_id, activity): + with spans.ProactiveSendActivity(conversation_id, activity, link=conversation._span_context) as span: return await Proactive._send_activity_impl(adapter, conversation, activity) @staticmethod @@ -266,7 +266,10 @@ async def _callback(context: TurnContext) -> None: except Exception as exc: # noqa: BLE001 captured_exc = exc - with spans.ProactiveContinueConversation(conversation_id, continuation): + with spans.ProactiveContinueConversation( + conversation_id, + continuation, + link=conversation._span_context): await adapter.continue_conversation_with_claims( claims, continuation, _callback diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/_utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/_utils.py new file mode 100644 index 00000000..9ceb2b82 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/_utils.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from opentelemetry.trace import SpanContext + +def _dump_span_context(span_context: SpanContext) -> dict: + """Dumps a SpanContext into a dictionary. + + :param span_context: The SpanContext to serialize + :type span_context: SpanContext + :return: A dictionary representation of the SpanContext + :rtype: dict + """ + data = { + "trace_id": span_context.trace_id, + "span_id": span_context.span_id, + "trace_flags": int(span_context.trace_flags), + "trace_state": list(span_context.trace_state), + "is_remote": span_context.is_remote, + } + return data + +def _deserialize_span_context(data: dict) -> SpanContext: + """Deserializes a dictionary into a SpanContext. + + :param data: The dictionary representation of the SpanContext + :type data: dict + :return: The deserialized SpanContext + :rtype: SpanContext + """ + return SpanContext( + trace_id=data["trace_id"], + span_id=data["span_id"], + trace_flags=data["trace_flags"], + trace_state=data["trace_state"], + is_remote=data["is_remote"], + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py index b80c5ecd..2d5794e3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py @@ -3,6 +3,8 @@ from __future__ import annotations +from opentelemetry.trace import SpanContext + from microsoft_agents.activity import Activity from microsoft_agents.hosting.core.telemetry import ( AttributeMap, @@ -78,13 +80,14 @@ def _get_attributes(self) -> AttributeMap: class ProactiveSendActivity(SimpleSpanWrapper): """Span for sending an activity in proactive scenarios, starting from when the send operation is initiated until it is completed. This span can be used to correlate telemetry related to sending activities in proactive scenarios.""" - def __init__(self, conversation_id: str, activity: Activity): + def __init__(self, conversation_id: str, activity: Activity, *, link: SpanContext | None = None): """Initializes the ProactiveSendActivity SpanWrapper. :param conversation_id: The ID of the conversation the activity is being sent to, used to extract attributes for the span :param activity: The activity being sent, used to extract attributes for the span + :param link: The span context to link to, used to correlate this span with other spans """ - super().__init__(constants.SPAN_SEND_ACTIVITY) + super().__init__(constants.SPAN_SEND_ACTIVITY, link=link) self._conversation_id = conversation_id self._activity = activity @@ -100,13 +103,14 @@ def _get_attributes(self) -> AttributeMap: class ProactiveContinueConversation(SimpleSpanWrapper): """Span for continuing a conversation in proactive scenarios, starting from when the continue operation is initiated until it is completed. This span can be used to correlate telemetry related to continuing conversations in proactive scenarios.""" - def __init__(self, conversation_id: str, activity: Activity): + def __init__(self, conversation_id: str, activity: Activity, *, link: SpanContext | None = None): """Initializes the ProactiveContinueConversation SpanWrapper. :param conversation_id: The ID of the conversation being continued, used to extract attributes for the span :param activity: The activity being sent, used to extract attributes for the span + :param link: The span context to link to, used to correlate this span with other spans """ - super().__init__(constants.SPAN_CONTINUE_CONVERSATION) + super().__init__(constants.SPAN_CONTINUE_CONVERSATION, link=link) self._conversation_id = conversation_id self._activity = activity diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py index 94030a47..cf08c3c6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py @@ -9,7 +9,7 @@ from opentelemetry.metrics import Meter from opentelemetry import metrics, trace -from opentelemetry.trace import Tracer, Span +from opentelemetry.trace import Tracer, Span, Link from .resource import SERVICE_NAME, SERVICE_VERSION from .type_defs import SpanCallback @@ -43,16 +43,18 @@ def start_as_current_span( self, span_name: str, callback: SpanCallback | None = None, + links: list[Link] | None = None, ) -> Iterator[Span]: """Context manager for starting a timed span that records duration and success/failure status, and invokes a callback with the results :param span_name: The name of the span to start :param callback: Optional callback function that will be called with the span, duration in milliseconds, and any exception that was raised (or None if successful) when the span is ended + :param links: Optional list of OpenTelemetry Link objects to associate with the span :return: An iterator that yields the started span, which will be ended when the context manager exits """ with self._tracer.start_as_current_span( - span_name, record_exception=False, set_status_on_exception=False + span_name, record_exception=False, set_status_on_exception=False, links=links ) as span: start = time.time() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py index 841275e9..77bdbbca 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -8,6 +8,7 @@ from abc import ABC, abstractmethod from contextlib import ExitStack from typing import ContextManager +from typing_extensions import Self from opentelemetry.trace import Span @@ -46,8 +47,7 @@ def _log_lifespan_error(desc: str) -> None: ) logger.warning("Description: %s", desc) - # TODO -> Add Self annotation once 3.11 is the minimum supported version - def __enter__(self): + def __enter__(self) -> Self: """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining. This method should check if the BaseSpanWrapper is already active and log a warning if an attempt is made to start an already active BaseSpanWrapper, to help identify potential issues with BaseSpanWrapper lifecycle management.""" if self._active: BaseSpanWrapper._log_lifespan_error( @@ -59,11 +59,11 @@ def __enter__(self): return self - def start(self) -> BaseSpanWrapper: + def start(self) -> Self: """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining""" return self.__enter__() - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type, exc_val, exc_tb) -> None: """Stops the BaseSpanWrapper if it is active, and logs a warning if an attempt is made to stop a BaseSpanWrapper that is not active. This ensures that BaseSpanWrappers are properly cleaned up and that potential issues with BaseSpanWrapper lifecycle management are logged for debugging purposes.""" if self._active: self._exit_stack.__exit__(exc_type, exc_val, exc_tb) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py index bb2e6349..b592482c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -5,7 +5,7 @@ from collections.abc import Iterator from contextlib import contextmanager -from opentelemetry.trace import Span +from opentelemetry.trace import Span, Link, SpanContext from ._agents_telemetry import agents_telemetry from .base_span_wrapper import BaseSpanWrapper @@ -15,9 +15,21 @@ class SimpleSpanWrapper(BaseSpanWrapper, ABC): """Simple implementation of the BaseSpanWrapper that can be used when no additional attributes or functionality are needed on the span beyond what is provided by the base BaseSpanWrapper class. This can be used as a simple wrapper around an OTEL span for cases where no SDK-specific telemetry is needed, while still providing the benefits of the BaseSpanWrapper abstraction and lifecycle management.""" - def __init__(self, span_name: str): + def __init__(self, span_name: str, *, link: Link | SpanContext | list[Link | SpanContext] | None = None) -> None: super().__init__() self._span_name = span_name + self._link = [] + + links_list: list[Link | SpanContext] + if isinstance(link, list): + links_list = link + else: + links_list = [link] if link is not None else [] + for item in links_list: + if isinstance(item, SpanContext): + self._link.append(Link(item)) + else: + self._link.append(item) def _get_attributes(self) -> AttributeMap: """Returns a dictionary of attributes to set on the span when it is started. This can be overridden by subclasses to provide custom attributes for the span based on the context in which it is being used.""" @@ -31,7 +43,9 @@ def _callback(self, span: Span, duration: float, error: Exception | None) -> Non def _start_span(self) -> Iterator[Span]: """Starts a basic OTEL span with the given name and no additional attributes.""" with agents_telemetry.start_as_current_span( - self._span_name, callback=self._callback + self._span_name, + callback=self._callback, + links=self._link, ) as span: try: yield span diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py index 0169e73f..02397c7d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py @@ -1,7 +1,7 @@ from typing import Mapping, Callable from opentelemetry.util.types import AttributeValue -from opentelemetry.trace import Span +from opentelemetry.trace import Span, Link AttributeMap = Mapping[str, AttributeValue] -SpanCallback = Callable[[Span, float, Exception | None], None] +SpanCallback = Callable[[Span, float, Exception | None], None] \ No newline at end of file From 225e3ed7ae82b1e0a36e634892e1ed9377a81043 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Aug 2026 10:47:59 -0700 Subject: [PATCH 3/8] Fixing issues with Proactive implementation --- .../hosting/core/app/proactive/_utils.py | 19 --- .../core/app/proactive/conversation.py | 8 +- .../app/proactive/conversation_builder.py | 53 ++++---- .../conversation_reference_builder.py | 60 +++++----- .../hosting/core/app/proactive/proactive.py | 30 +++-- .../core/app/proactive/proactive_options.py | 2 +- .../core/app/proactive/telemetry/_utils.py | 20 ++-- .../core/app/proactive/telemetry/spans.py | 16 ++- .../core/telemetry/core/_agents_telemetry.py | 5 +- .../telemetry/core/simple_span_wrapper.py | 7 +- .../hosting/core/telemetry/core/type_defs.py | 2 +- .../hosting/msteams/_http_client.py | 3 +- .../test_create_conversation_options.py | 44 +++---- .../app/proactive/test_proactive.py | 6 +- .../telemetry/test_proactive_utils.py | 113 ++++++++++++++++++ .../telemetry/test_simple_span_wrapper.py | 61 +++++++++- 16 files changed, 323 insertions(+), 126 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/_utils.py create mode 100644 tests/hosting_core/telemetry/test_proactive_utils.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/_utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/_utils.py deleted file mode 100644 index 42b74c38..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/_utils.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from microsoft_agents.hosting.core.telemetry.core import BaseSpanWrapper -from .conversation import Conversation - -def _link_to_conversation(conversation: Conversation, span: BaseSpanWrapper) -> None: - """Links the given span to the conversation reference of the given conversation, if it exists. - This allows telemetry related to the span to be correlated with telemetry related to the conversation reference, - enabling better observability and debugging of proactive scenarios. - - :param conversation: The conversation whose conversation reference should be linked to the span - :type conversation: Conversation - :param span: The span to link to the conversation reference - :type span: BaseSpanWrapper - """ - if conversation.conversation_reference is not None: - - span.otel_span.add_link(conversation.conversation_reference.to_span_link()) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py index 43aea72d..08b980ee 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py @@ -49,7 +49,7 @@ def __init__( _span_context: dict | None = None, ) -> None: """Creates a new :class:`~microsoft_agents.hosting.core.app.proactive.Conversation` instance. - + :param claims: Filtered JWT claims (``aud``, ``azp``, ``appid``, ``idtyp``, ``ver``, ``iss``, ``tid``). May be a raw ``dict`` or a :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`. @@ -70,7 +70,7 @@ def __init__( def _set_span_context(self, span_context: SpanContext) -> None: """Sets the span context for this conversation, serializing it to a dictionary for storage. - + For internal use only; this is not part of the public API. :param span_context: The SpanContext to set. @@ -80,7 +80,7 @@ def _set_span_context(self, span_context: SpanContext) -> None: def _get_span_context(self) -> SpanContext | None: """Gets the span context for this conversation, deserializing it from a dictionary. - + For internal use only; this is not part of the public API. :return: The SpanContext, or None if not set. @@ -186,5 +186,5 @@ def from_json_to_store_item(json_data: dict) -> Conversation: return Conversation( claims=json_data.get("claims", {}), conversation_reference=reference, - _span_context=json_data.get("_span_context", None) + _span_context=json_data.get("_span_context", None), ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py index fdfd0892..d1e817a0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py @@ -5,14 +5,14 @@ from __future__ import annotations -from typing import Optional - from microsoft_agents.activity import ( ChannelAccount, Channels, + ChannelId, ConversationAccount, ConversationReference, ) +from microsoft_agents.activity._model_utils import pick_model, SkipNone from microsoft_agents.hosting.core.authorization import ClaimsIdentity from .conversation import Conversation @@ -45,16 +45,16 @@ class ConversationBuilder: def __init__(self) -> None: self._claims: dict[str, str] = {} - self._channel_id: Optional[str] = None - self._service_url: Optional[str] = None - self._agent_id: Optional[str] = None - self._agent_name: Optional[str] = None - self._user_id: Optional[str] = None - self._user_name: Optional[str] = None - self._conversation_id: Optional[str] = None - self._conversation_name: Optional[str] = None - self._tenant_id: Optional[str] = None - self._activity_id: Optional[str] = None + self._channel_id: str | None = None + self._service_url: str | None = None + self._agent_id: str | None = None + self._agent_name: str | None = None + self._user_id: str | None = None + self._user_name: str | None = None + self._conversation_id: str | None = None + self._conversation_name: str | None = None + self._tenant_id: str | None = None + self._activity_id: str | None = None # ------------------------------------------------------------------ # Entry-point factories @@ -65,8 +65,8 @@ def create( cls, agent_client_id: str, channel_id: str, - service_url: Optional[str] = None, - requestor_id: Optional[str] = None, + service_url: str | None = None, + requestor_id: str | None = None, ) -> "ConversationBuilder": """ Start building a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` @@ -79,7 +79,7 @@ def create( :type channel_id: str :param service_url: Override the service URL. Defaults to the canonical URL for *channel_id*. - :type service_url: Optional[str] + :type service_url: str | None :param requestor_id: If provided, stored as the ``appid`` claim (useful when the requestor differs from the audience). :type requestor_id: Optional[str] @@ -106,7 +106,7 @@ def create_from_identity( cls, identity: ClaimsIdentity, channel_id: str, - service_url: Optional[str] = None, + service_url: str | None = None, ) -> "ConversationBuilder": """ Start building a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` @@ -117,7 +117,7 @@ def create_from_identity( :param channel_id: The channel identifier. :type channel_id: str :param service_url: Override the service URL. - :type service_url: Optional[str] + :type service_url: str | None :return: A builder pre-populated with the identity's claims. :rtype: :class:`microsoft_agents.hosting.core.app.proactive.ConversationBuilder` """ @@ -142,7 +142,7 @@ def create_from_identity( def with_user( self, user_id: str, - user_name: Optional[str] = None, + user_name: str | None = None, ) -> "ConversationBuilder": """ Set the user account. @@ -161,8 +161,8 @@ def with_user( def with_conversation( self, conversation_id: str, - conversation_name: Optional[str] = None, - tenant_id: Optional[str] = None, + conversation_name: str | None = None, + tenant_id: str | None = None, ) -> "ConversationBuilder": """ Set the conversation account details. @@ -211,25 +211,28 @@ def build(self) -> Conversation: raise ValueError("ConversationBuilder: conversation_id is required.") agent = ( - ChannelAccount(id=self._agent_id, name=self._agent_name) + pick_model( + ChannelAccount, id=self._agent_id, name=SkipNone(self._agent_name) + ) if self._agent_id else None ) user = ( - ChannelAccount(id=self._user_id, name=self._user_name) + pick_model(ChannelAccount, id=self._user_id, name=SkipNone(self._user_name)) if self._user_id else None ) - reference = ConversationReference( - channel_id=self._channel_id, + reference = pick_model( + ConversationReference, + channel_id=ChannelId(self._channel_id), service_url=self._service_url or _service_url_for_channel(self._channel_id), conversation=ConversationAccount( id=self._conversation_id, name=self._conversation_name, tenant_id=self._tenant_id, ), - bot=agent, + agent=SkipNone(agent), user=user, activity_id=self._activity_id, ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py index e7316a84..33fe3004 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py @@ -5,14 +5,13 @@ from __future__ import annotations -from typing import Optional - from microsoft_agents.activity import ( ChannelAccount, Channels, ConversationAccount, ConversationReference, ) +from microsoft_agents.activity._model_utils import pick_model, SkipNone def _service_url_for_channel(channel_id: str) -> str: @@ -45,15 +44,15 @@ class ConversationReferenceBuilder: """ def __init__(self) -> None: - self._channel_id: Optional[str] = None - self._conversation_id: Optional[str] = None - self._service_url: Optional[str] = None - self._agent_id: Optional[str] = None - self._agent_name: Optional[str] = None - self._user_id: Optional[str] = None - self._user_name: Optional[str] = None - self._activity_id: Optional[str] = None - self._locale: Optional[str] = None + self._channel_id: str | None = None + self._conversation_id: str | None = None + self._service_url: str | None = None + self._agent_id: str | None = None + self._agent_name: str | None = None + self._user_id: str | None = None + self._user_name: str | None = None + self._activity_id: str | None = None + self._locale: str | None = None # ------------------------------------------------------------------ # Entry-point factories @@ -64,7 +63,7 @@ def create( cls, channel_id: str, conversation_id: str, - ) -> "ConversationReferenceBuilder": + ) -> ConversationReferenceBuilder: """ Start building a :class:`~microsoft_agents.activity.ConversationReference` from a channel ID and an existing conversation ID. @@ -86,8 +85,8 @@ def create_for_agent( cls, agent_client_id: str, channel_id: str, - service_url: Optional[str] = None, - ) -> "ConversationReferenceBuilder": + service_url: str | None = None, + ) -> ConversationReferenceBuilder: """ Start building a :class:`~microsoft_agents.activity.ConversationReference` from an agent application ID and channel. @@ -101,7 +100,7 @@ def create_for_agent( :type channel_id: str :param service_url: Override the service URL. When ``None`` the default URL for the channel is used. - :type service_url: Optional[str] + :type service_url: str | None :return: A builder pre-populated for the agent. :rtype: :class:`microsoft_agents.hosting.core.app.proactive.ConversationReferenceBuilder` """ @@ -124,15 +123,15 @@ def create_for_agent( def with_agent( self, agent_id: str, - agent_name: Optional[str] = None, - ) -> "ConversationReferenceBuilder": + agent_name: str | None = None, + ) -> ConversationReferenceBuilder: """ Set the agent (bot) account on the reference. :param agent_id: The agent's channel account ID. :type agent_id: str :param agent_name: Optional display name. - :type agent_name: Optional[str] + :type agent_name: str | None :return: ``self`` for chaining. :rtype: :class:`microsoft_agents.hosting.core.app.proactive.ConversationReferenceBuilder` """ @@ -143,15 +142,15 @@ def with_agent( def with_user( self, user_id: str, - user_name: Optional[str] = None, - ) -> "ConversationReferenceBuilder": + user_name: str | None = None, + ) -> ConversationReferenceBuilder: """ Set the user account on the reference. :param user_id: The user's channel account ID. :type user_id: str :param user_name: Optional display name. - :type user_name: Optional[str] + :type user_name: str | None :return: ``self`` for chaining. :rtype: :class:`microsoft_agents.hosting.core.app.proactive.ConversationReferenceBuilder` """ @@ -159,7 +158,7 @@ def with_user( self._user_name = user_name return self - def with_service_url(self, service_url: str) -> "ConversationReferenceBuilder": + def with_service_url(self, service_url: str) -> ConversationReferenceBuilder: """ Override the service URL. @@ -171,7 +170,7 @@ def with_service_url(self, service_url: str) -> "ConversationReferenceBuilder": self._service_url = service_url return self - def with_activity_id(self, activity_id: str) -> "ConversationReferenceBuilder": + def with_activity_id(self, activity_id: str) -> ConversationReferenceBuilder: """ Set the activity ID on the reference. @@ -183,7 +182,7 @@ def with_activity_id(self, activity_id: str) -> "ConversationReferenceBuilder": self._activity_id = activity_id return self - def with_locale(self, locale: str) -> "ConversationReferenceBuilder": + def with_locale(self, locale: str) -> ConversationReferenceBuilder: """ Set the locale on the reference. @@ -217,22 +216,25 @@ def build(self) -> ConversationReference: service_url = self._service_url or _service_url_for_channel(self._channel_id) agent = ( - ChannelAccount(id=self._agent_id, name=self._agent_name) + pick_model( + ChannelAccount, id=self._agent_id, name=SkipNone(self._agent_name) + ) if self._agent_id else None ) user = ( - ChannelAccount(id=self._user_id, name=self._user_name) + pick_model(ChannelAccount, id=self._user_id, name=SkipNone(self._user_name)) if self._user_id else None ) - return ConversationReference( + return pick_model( + ConversationReference, channel_id=self._channel_id, conversation=ConversationAccount(id=self._conversation_id), service_url=service_url, - bot=agent, - user=user, + agent=SkipNone(agent), + user=SkipNone(user), activity_id=self._activity_id, locale=self._locale, ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py index 86151758..b02fced0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -9,6 +9,7 @@ from microsoft_agents.activity import Activity, ResourceResponse from microsoft_agents.hosting.core.app.state.turn_state import TurnState +from microsoft_agents.hosting.core.storage import Storage from .conversation import Conversation from .create_conversation_options import CreateConversationOptions @@ -68,12 +69,22 @@ def __init__( ) -> None: self._app = app self._options = options - self._storage = self._options.storage @staticmethod def _storage_key(conversation_id: str) -> str: + """Get the storage key for a given conversation ID.""" return f"{_STORAGE_KEY_PREFIX}{conversation_id}" + @property + def _storage(self) -> Storage: + """Get the configured storage instance, or raise if not configured.""" + if self._options.storage is None: + raise RuntimeError( + "Proactive storage is not configured. Provide a Storage instance " + "via ProactiveOptions.storage." + ) + return self._options.storage + # ------------------------------------------------------------------ # Conversation persistence # ------------------------------------------------------------------ @@ -109,7 +120,9 @@ async def store_conversation( conversation.conversation_reference.conversation.id ) as span: if span.otel_span is not None: - conversation._set_span_context(span.otel_span.get_span_context()) + span_context = span.otel_span.get_span_context() + if span_context.is_valid: + conversation._set_span_context(span_context) conversation.validate() key = self._storage_key(conversation.conversation_reference.conversation.id) logger.debug("Storing conversation with key: %s", key) @@ -176,7 +189,9 @@ async def send_activity( """ conversation = await self._resolve_conversation(conversation_id_or_conversation) conversation_id = conversation.conversation_reference.conversation.id - with spans.ProactiveSendActivity(conversation_id, activity, link=conversation._span_context) as span: + with spans.ProactiveSendActivity( + conversation_id, activity, link=conversation._get_span_context() + ): return await Proactive._send_activity_impl(adapter, conversation, activity) @staticmethod @@ -186,7 +201,7 @@ async def _send_activity_impl( activity: Activity, ) -> ResourceResponse | None: """Send an activity into a conversation without loading state or running a handler.""" - + result: ResourceResponse | None = None captured_exc: BaseException | None = None @@ -267,9 +282,8 @@ async def _callback(context: TurnContext) -> None: captured_exc = exc with spans.ProactiveContinueConversation( - conversation_id, - continuation, - link=conversation._span_context): + conversation_id, continuation, link=conversation._get_span_context() + ): await adapter.continue_conversation_with_claims( claims, continuation, _callback @@ -359,7 +373,7 @@ async def _on_turn( """Run a proactive turn: load state → optional OAuth check → handler → save state.""" state = await self._load_state(context) - if token_handlers and self._app._auth: + if token_handlers: for handler_id in token_handlers: result = await self._app._auth._start_or_continue_sign_in( context, state, handler_id diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py index a43fdc40..219978c4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py @@ -24,7 +24,7 @@ class ProactiveOptions: :type fail_on_unsigned_in_connections: bool """ - storage: Storage + storage: Storage | None = None """Storage used to persist Conversation objects.""" fail_on_unsigned_in_connections: bool = True diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/_utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/_utils.py index 9ceb2b82..932d3deb 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/_utils.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/_utils.py @@ -1,7 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from opentelemetry.trace import SpanContext +from opentelemetry.trace import SpanContext, TraceFlags, TraceState + def _dump_span_context(span_context: SpanContext) -> dict: """Dumps a SpanContext into a dictionary. @@ -12,14 +13,15 @@ def _dump_span_context(span_context: SpanContext) -> dict: :rtype: dict """ data = { - "trace_id": span_context.trace_id, - "span_id": span_context.span_id, + "trace_id": str(span_context.trace_id), + "span_id": str(span_context.span_id), "trace_flags": int(span_context.trace_flags), - "trace_state": list(span_context.trace_state), + "trace_state": list(span_context.trace_state.items()), "is_remote": span_context.is_remote, } return data + def _deserialize_span_context(data: dict) -> SpanContext: """Deserializes a dictionary into a SpanContext. @@ -29,9 +31,9 @@ def _deserialize_span_context(data: dict) -> SpanContext: :rtype: SpanContext """ return SpanContext( - trace_id=data["trace_id"], - span_id=data["span_id"], - trace_flags=data["trace_flags"], - trace_state=data["trace_state"], + trace_id=int(data["trace_id"]), + span_id=int(data["span_id"]), + trace_flags=TraceFlags(data["trace_flags"]), + trace_state=TraceState(data["trace_state"]), is_remote=data["is_remote"], - ) \ No newline at end of file + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py index 2d5794e3..77c67231 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/telemetry/spans.py @@ -80,7 +80,13 @@ def _get_attributes(self) -> AttributeMap: class ProactiveSendActivity(SimpleSpanWrapper): """Span for sending an activity in proactive scenarios, starting from when the send operation is initiated until it is completed. This span can be used to correlate telemetry related to sending activities in proactive scenarios.""" - def __init__(self, conversation_id: str, activity: Activity, *, link: SpanContext | None = None): + def __init__( + self, + conversation_id: str, + activity: Activity, + *, + link: SpanContext | None = None, + ): """Initializes the ProactiveSendActivity SpanWrapper. :param conversation_id: The ID of the conversation the activity is being sent to, used to extract attributes for the span @@ -103,7 +109,13 @@ def _get_attributes(self) -> AttributeMap: class ProactiveContinueConversation(SimpleSpanWrapper): """Span for continuing a conversation in proactive scenarios, starting from when the continue operation is initiated until it is completed. This span can be used to correlate telemetry related to continuing conversations in proactive scenarios.""" - def __init__(self, conversation_id: str, activity: Activity, *, link: SpanContext | None = None): + def __init__( + self, + conversation_id: str, + activity: Activity, + *, + link: SpanContext | None = None, + ): """Initializes the ProactiveContinueConversation SpanWrapper. :param conversation_id: The ID of the conversation being continued, used to extract attributes for the span diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py index cf08c3c6..6548130c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py @@ -54,7 +54,10 @@ def start_as_current_span( """ with self._tracer.start_as_current_span( - span_name, record_exception=False, set_status_on_exception=False, links=links + span_name, + record_exception=False, + set_status_on_exception=False, + links=links, ) as span: start = time.time() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py index b592482c..5ace0b55 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -15,7 +15,12 @@ class SimpleSpanWrapper(BaseSpanWrapper, ABC): """Simple implementation of the BaseSpanWrapper that can be used when no additional attributes or functionality are needed on the span beyond what is provided by the base BaseSpanWrapper class. This can be used as a simple wrapper around an OTEL span for cases where no SDK-specific telemetry is needed, while still providing the benefits of the BaseSpanWrapper abstraction and lifecycle management.""" - def __init__(self, span_name: str, *, link: Link | SpanContext | list[Link | SpanContext] | None = None) -> None: + def __init__( + self, + span_name: str, + *, + link: Link | SpanContext | list[Link | SpanContext] | None = None + ) -> None: super().__init__() self._span_name = span_name self._link = [] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py index 02397c7d..d01e0ee0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py @@ -4,4 +4,4 @@ from opentelemetry.trace import Span, Link AttributeMap = Mapping[str, AttributeValue] -SpanCallback = Callable[[Span, float, Exception | None], None] \ No newline at end of file +SpanCallback = Callable[[Span, float, Exception | None], None] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py index c3afe1f3..e93bba20 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py @@ -7,7 +7,6 @@ from microsoft_teams.common import Client, ClientOptions - _ssl_context: ssl.SSLContext | None = None @@ -32,4 +31,4 @@ def _create_http_client(options: ClientOptions | None = None) -> Client: verify=_get_ssl_context(), ) client._update_event_hooks() - return client \ No newline at end of file + return client diff --git a/tests/hosting_core/app/proactive/test_create_conversation_options.py b/tests/hosting_core/app/proactive/test_create_conversation_options.py index 6353b3f2..665c8193 100644 --- a/tests/hosting_core/app/proactive/test_create_conversation_options.py +++ b/tests/hosting_core/app/proactive/test_create_conversation_options.py @@ -19,28 +19,28 @@ def _make_params(): class TestCreateConversationOptionsDefaults: - def test_default_identity_is_none(self): - opts = CreateConversationOptions() - assert opts.identity is None + def test_identity_is_required(self): + with pytest.raises(TypeError, match="identity"): + CreateConversationOptions() def test_default_channel_id_is_empty(self): - opts = CreateConversationOptions() + opts = CreateConversationOptions(identity=_make_identity()) assert opts.channel_id == "" def test_default_parameters_is_none(self): - opts = CreateConversationOptions() + opts = CreateConversationOptions(identity=_make_identity()) assert opts.parameters is None def test_default_service_url_is_none(self): - opts = CreateConversationOptions() + opts = CreateConversationOptions(identity=_make_identity()) assert opts.service_url is None def test_default_audience_is_none(self): - opts = CreateConversationOptions() + opts = CreateConversationOptions(identity=_make_identity()) assert opts.audience is None def test_default_store_conversation_is_false(self): - opts = CreateConversationOptions() + opts = CreateConversationOptions(identity=_make_identity()) assert opts.store_conversation is False @@ -51,24 +51,33 @@ def test_identity_assigned(self): assert opts.identity is identity def test_channel_id_assigned(self): - opts = CreateConversationOptions(channel_id="msteams") + opts = CreateConversationOptions( + identity=_make_identity(), channel_id="msteams" + ) assert opts.channel_id == "msteams" def test_parameters_assigned(self): params = _make_params() - opts = CreateConversationOptions(parameters=params) + opts = CreateConversationOptions(identity=_make_identity(), parameters=params) assert opts.parameters is params def test_service_url_assigned(self): - opts = CreateConversationOptions(service_url="https://custom/") + opts = CreateConversationOptions( + identity=_make_identity(), service_url="https://custom/" + ) assert opts.service_url == "https://custom/" def test_audience_assigned(self): - opts = CreateConversationOptions(audience="https://api.botframework.com") + opts = CreateConversationOptions( + identity=_make_identity(), + audience="https://api.botframework.com", + ) assert opts.audience == "https://api.botframework.com" def test_store_conversation_assigned(self): - opts = CreateConversationOptions(store_conversation=True) + opts = CreateConversationOptions( + identity=_make_identity(), store_conversation=True + ) assert opts.store_conversation is True @@ -92,13 +101,6 @@ def test_validate_optional_fields_not_required(self): ) opts.validate() # must not raise - def test_validate_raises_when_identity_missing(self): - opts = CreateConversationOptions( - channel_id="msteams", parameters=_make_params() - ) - with pytest.raises(ValueError, match="identity"): - opts.validate() - def test_validate_raises_when_channel_id_empty(self): opts = CreateConversationOptions( identity=_make_identity(), parameters=_make_params() @@ -114,6 +116,6 @@ def test_validate_raises_when_parameters_missing(self): opts.validate() def test_validate_raises_when_all_missing(self): - opts = CreateConversationOptions() + opts = CreateConversationOptions(identity=_make_identity()) with pytest.raises(ValueError): opts.validate() diff --git a/tests/hosting_core/app/proactive/test_proactive.py b/tests/hosting_core/app/proactive/test_proactive.py index e41c1038..8469acab 100644 --- a/tests/hosting_core/app/proactive/test_proactive.py +++ b/tests/hosting_core/app/proactive/test_proactive.py @@ -520,10 +520,12 @@ async def test_create_calls_adapter_create_conversation(self, proactive, options adapter.create_conversation.assert_called_once() @pytest.mark.asyncio - async def test_create_validates_options(self, proactive): + async def test_create_validates_options(self, proactive, identity): adapter = MagicMock() with pytest.raises(ValueError): - await proactive.create_conversation(adapter, CreateConversationOptions()) + await proactive.create_conversation( + adapter, CreateConversationOptions(identity=identity) + ) @pytest.mark.asyncio async def test_create_stores_conversation_when_flag_set(self, proactive, identity): diff --git a/tests/hosting_core/telemetry/test_proactive_utils.py b/tests/hosting_core/telemetry/test_proactive_utils.py new file mode 100644 index 00000000..ebb2cd85 --- /dev/null +++ b/tests/hosting_core/telemetry/test_proactive_utils.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from opentelemetry.trace import SpanContext, TraceFlags, TraceState + +from microsoft_agents.hosting.core.app.proactive.telemetry._utils import ( + _deserialize_span_context, + _dump_span_context, +) + + +def _make_span_context( + *, + is_remote: bool = False, + trace_flags: TraceFlags = TraceFlags(TraceFlags.SAMPLED), + trace_state: TraceState | None = None, +) -> SpanContext: + return SpanContext( + trace_id=0x4BF92F3577B34DA6A3CE929D0E0E4736, + span_id=0x00F067AA0BA902B7, + is_remote=is_remote, + trace_flags=trace_flags, + trace_state=trace_state or TraceState(), + ) + + +def test_dump_span_context_serializes_all_fields(): + context = _make_span_context( + is_remote=True, + trace_state=TraceState([("vendor", "value")]), + ) + + result = _dump_span_context(context) + + assert result == { + "trace_id": context.trace_id, + "span_id": context.span_id, + "trace_flags": int(context.trace_flags), + "trace_state": [("vendor", "value")], + "is_remote": True, + } + + +def test_dump_span_context_serializes_empty_trace_state(): + context = _make_span_context() + + result = _dump_span_context(context) + + assert result["trace_state"] == [] + + +def test_deserialize_span_context_restores_all_fields(): + result = _deserialize_span_context( + { + "trace_id": 0x4BF92F3577B34DA6A3CE929D0E0E4736, + "span_id": 0x00F067AA0BA902B7, + "trace_flags": TraceFlags.SAMPLED, + "trace_state": [("vendor", "value")], + "is_remote": True, + } + ) + + assert result.trace_id == 0x4BF92F3577B34DA6A3CE929D0E0E4736 + assert result.span_id == 0x00F067AA0BA902B7 + assert result.trace_flags == TraceFlags(TraceFlags.SAMPLED) + assert result.trace_state == TraceState([("vendor", "value")]) + assert result.is_remote is True + assert result.is_valid is True + + +def test_deserialize_span_context_restores_unsampled_flags(): + result = _deserialize_span_context( + { + "trace_id": 1, + "span_id": 2, + "trace_flags": TraceFlags.DEFAULT, + "trace_state": [], + "is_remote": False, + } + ) + + assert result.trace_flags == TraceFlags(TraceFlags.DEFAULT) + assert result.trace_flags.sampled is False + + +def test_span_context_round_trip_preserves_context(): + context = _make_span_context( + is_remote=True, + trace_state=TraceState( + [ + ("vendor", "value"), + ("tenant", "contoso"), + ] + ), + ) + + result = _deserialize_span_context(_dump_span_context(context)) + + assert result == context + + +def test_invalid_span_context_round_trip_remains_invalid(): + context = SpanContext( + trace_id=0, + span_id=0, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.DEFAULT), + trace_state=TraceState(), + ) + + result = _deserialize_span_context(_dump_span_context(context)) + + assert result.is_valid is False diff --git a/tests/hosting_core/telemetry/test_simple_span_wrapper.py b/tests/hosting_core/telemetry/test_simple_span_wrapper.py index 37f83fb0..fd51e5de 100644 --- a/tests/hosting_core/telemetry/test_simple_span_wrapper.py +++ b/tests/hosting_core/telemetry/test_simple_span_wrapper.py @@ -2,7 +2,13 @@ import pytest -from opentelemetry.trace import StatusCode +from opentelemetry.trace import ( + Link, + SpanContext, + StatusCode, + TraceFlags, + TraceState, +) from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures test_telemetry, @@ -38,6 +44,16 @@ def __init__(self, span_name): super().__init__(span_name) +def _make_span_context(trace_id: int, span_id: int) -> SpanContext: + return SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=True, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + trace_state=TraceState(), + ) + + class TestSimpleSpanWrapper: def test_simple_span_wrapper(self, test_exporter): """Test that MySpanWrapper creates a span with the correct attributes and callback.""" @@ -252,3 +268,46 @@ def test_custom_attributes_set_when_span_body_fails(self, test_exporter): assert span.attributes["custom_attribute"] == "custom_value" assert span.attributes["callback_called"] is True assert span.attributes["exception_message"] == "boom" + + def test_span_context_link_is_added_to_span(self, test_exporter): + """A SpanContext passed as link is converted into an exported span link.""" + context = _make_span_context(trace_id=1, span_id=2) + + with SimpleSpanWrapper("span_context_link", link=context): + pass + + span = test_exporter.get_finished_spans()[0] + assert len(span.links) == 1 + assert span.links[0].context == context + + def test_explicit_link_is_added_with_attributes(self, test_exporter): + """An explicit Link preserves its context and link attributes.""" + context = _make_span_context(trace_id=3, span_id=4) + link = Link(context, attributes={"relationship": "proactive"}) + + with SimpleSpanWrapper("explicit_link", link=link): + pass + + span = test_exporter.get_finished_spans()[0] + assert len(span.links) == 1 + assert span.links[0].context == context + assert span.links[0].attributes["relationship"] == "proactive" + + def test_multiple_mixed_links_are_added_in_order(self, test_exporter): + """A list may contain both SpanContext and Link instances.""" + first_context = _make_span_context(trace_id=5, span_id=6) + second_context = _make_span_context(trace_id=7, span_id=8) + second_link = Link(second_context, attributes={"position": "second"}) + + with SimpleSpanWrapper( + "multiple_links", + link=[first_context, second_link], + ): + pass + + span = test_exporter.get_finished_spans()[0] + assert len(span.links) == 2 + assert span.links[0].context == first_context + assert span.links[0].attributes == {} + assert span.links[1].context == second_context + assert span.links[1].attributes["position"] == "second" From 59d5da32cdd4a5374a6cb7c9d1ef7e883bfeab19 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Aug 2026 10:55:16 -0700 Subject: [PATCH 4/8] Fixing tests --- .../app/proactive/conversation_builder.py | 34 +++++++++---------- .../hosting/core/app/proactive/proactive.py | 10 +++--- .../proactive/test_conversation_builder.py | 31 +++++++++-------- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py index d1e817a0..843ab295 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py @@ -12,7 +12,6 @@ ConversationAccount, ConversationReference, ) -from microsoft_agents.activity._model_utils import pick_model, SkipNone from microsoft_agents.hosting.core.authorization import ClaimsIdentity from .conversation import Conversation @@ -38,6 +37,7 @@ class ConversationBuilder: conversation = ( ConversationBuilder .create_from_identity(claims_identity, "msteams") + .with_user("user-aad-oid") .with_conversation("19:thread-id@thread.v2") .build() ) @@ -201,7 +201,7 @@ def build(self) -> Conversation: """ Construct the :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. - :raises ValueError: If required fields (``channel_id``, ``conversation_id``) are missing. + :raises ValueError: If a required identifier (channel, conversation, agent, or user) is missing. :return: The built :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. :rtype: :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` """ @@ -209,22 +209,20 @@ def build(self) -> Conversation: raise ValueError("ConversationBuilder: channel_id is required.") if not self._conversation_id: raise ValueError("ConversationBuilder: conversation_id is required.") + if not self._agent_id: + raise ValueError("ConversationBuilder: agent_id is required.") + if not self._user_id: + raise ValueError("ConversationBuilder: user_id is required.") - agent = ( - pick_model( - ChannelAccount, id=self._agent_id, name=SkipNone(self._agent_name) - ) - if self._agent_id - else None - ) - user = ( - pick_model(ChannelAccount, id=self._user_id, name=SkipNone(self._user_name)) - if self._user_id - else None - ) + agent_values = {"id": self._agent_id} + if self._agent_name is not None: + agent_values["name"] = self._agent_name + + user_values = {"id": self._user_id} + if self._user_name is not None: + user_values["name"] = self._user_name - reference = pick_model( - ConversationReference, + reference = ConversationReference( channel_id=ChannelId(self._channel_id), service_url=self._service_url or _service_url_for_channel(self._channel_id), conversation=ConversationAccount( @@ -232,8 +230,8 @@ def build(self) -> Conversation: name=self._conversation_name, tenant_id=self._tenant_id, ), - agent=SkipNone(agent), - user=user, + agent=ChannelAccount(**agent_values), + user=ChannelAccount(**user_values), activity_id=self._activity_id, ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py index b02fced0..f3557830 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -77,13 +77,13 @@ def _storage_key(conversation_id: str) -> str: @property def _storage(self) -> Storage: - """Get the configured storage instance, or raise if not configured.""" - if self._options.storage is None: + storage = self._options.storage or self._app.options.storage + if not storage: raise RuntimeError( - "Proactive storage is not configured. Provide a Storage instance " - "via ProactiveOptions.storage." + "Proactive messaging requires a Storage instance. " + "Configure ProactiveOptions.storage or ApplicationOptions.storage." ) - return self._options.storage + return storage # ------------------------------------------------------------------ # Conversation persistence diff --git a/tests/hosting_core/app/proactive/test_conversation_builder.py b/tests/hosting_core/app/proactive/test_conversation_builder.py index 931e44ea..2f3a8dd4 100644 --- a/tests/hosting_core/app/proactive/test_conversation_builder.py +++ b/tests/hosting_core/app/proactive/test_conversation_builder.py @@ -17,16 +17,11 @@ def _prep_build(builder: ConversationBuilder) -> ConversationBuilder: - """Make a ConversationBuilder ready to call .build() without Pydantic errors. - - The implementation passes _agent_name and _conversation_id directly to - Pydantic models that reject None / empty-string values. There is no public - API to set these on the builder, so tests set them directly. - """ - if builder._agent_id and builder._agent_name is None: - builder._agent_name = "Agent" + """Make a ConversationBuilder ready to call .build().""" if not builder._conversation_id: builder._conversation_id = "conv-1" + if not builder._user_id: + builder.with_user("user-1") return builder @@ -212,17 +207,23 @@ def test_build_sets_agent_with_teams_prefix(self): conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() assert conv.conversation_reference.agent.id == "28:app-id" - def test_build_no_agent_when_id_none(self): - # When _agent_id is not set, build() passes bot=None to ConversationReference. - # ConversationReference.agent has Field(None, alias="bot") with ChannelAccount type, - # so explicitly passing bot=None raises a Pydantic ValidationError. - from pydantic import ValidationError - + def test_build_requires_agent_id(self): builder = ConversationBuilder() builder._channel_id = "directline" builder._service_url = "https://directline.botframework.com/" builder._conversation_id = "conv-placeholder" - with pytest.raises(ValidationError): + builder._user_id = "user-id" + with pytest.raises( + ValueError, match="ConversationBuilder: agent_id is required" + ): + builder.build() + + def test_build_requires_user_id(self): + builder = ConversationBuilder.create("app-id", "directline") + builder._conversation_id = "conv-placeholder" + with pytest.raises( + ValueError, match="ConversationBuilder: user_id is required" + ): builder.build() def test_build_sets_user(self): From 37800f741fd986049a0571988620e99a233b1025 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Aug 2026 11:02:57 -0700 Subject: [PATCH 5/8] Fixing serialization tests --- tests/hosting_core/telemetry/test_proactive_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/hosting_core/telemetry/test_proactive_utils.py b/tests/hosting_core/telemetry/test_proactive_utils.py index ebb2cd85..83ea9488 100644 --- a/tests/hosting_core/telemetry/test_proactive_utils.py +++ b/tests/hosting_core/telemetry/test_proactive_utils.py @@ -33,8 +33,8 @@ def test_dump_span_context_serializes_all_fields(): result = _dump_span_context(context) assert result == { - "trace_id": context.trace_id, - "span_id": context.span_id, + "trace_id": str(context.trace_id), + "span_id": str(context.span_id), "trace_flags": int(context.trace_flags), "trace_state": [("vendor", "value")], "is_remote": True, From 9a486663f538363b12560e6302ac0e533dc6ad33 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Aug 2026 11:05:26 -0700 Subject: [PATCH 6/8] Addressing PR feedback --- .../hosting/core/app/agent_application.py | 2 +- .../core/app/proactive/conversation.py | 2 -- .../core/app/proactive/proactive_options.py | 2 +- .../hosting/core/telemetry/core/type_defs.py | 2 +- .../hosting/msteams/_http_client.py | 34 ------------------- 5 files changed, 3 insertions(+), 39 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 0039eefa..f81ac303 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -80,7 +80,7 @@ class AgentApplication(Agent, Generic[StateT]): _adapter: ChannelServiceAdapter | None = None _adaptive_card: AdaptiveCard _auth: Authorization - _proactive: Proactive + _proactive: Proactive | None = None _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] _route_list: _RouteList[StateT] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py index 08b980ee..a65fdd3c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py @@ -3,8 +3,6 @@ from __future__ import annotations -import functools - from typing import TYPE_CHECKING from opentelemetry.trace import SpanContext diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py index 219978c4..9780b5da 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py @@ -16,7 +16,7 @@ class ProactiveOptions: Options for the Proactive messaging subsystem. :param storage: The storage instance used to persist and retrieve conversations. - :type storage: :class:`microsoft_agents.hosting.core.storage.Storage` + :type storage: :class:`microsoft_agents.hosting.core.storage.Storage` | None :param fail_on_unsigned_in_connections: If ``True`` (the default), a :exc:`RuntimeError` is raised when a required OAuth token is not available during a proactive continuation. Set to ``False`` to silently skip the diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py index d01e0ee0..0169e73f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py @@ -1,7 +1,7 @@ from typing import Mapping, Callable from opentelemetry.util.types import AttributeValue -from opentelemetry.trace import Span, Link +from opentelemetry.trace import Span AttributeMap = Mapping[str, AttributeValue] SpanCallback = Callable[[Span, float, Exception | None], None] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py deleted file mode 100644 index e93bba20..00000000 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_http_client.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import ssl -import certifi -import httpx - -from microsoft_teams.common import Client, ClientOptions - -_ssl_context: ssl.SSLContext | None = None - - -def _get_ssl_context() -> ssl.SSLContext: - global _ssl_context - - if _ssl_context is None: - _ssl_context = ssl.create_default_context(cafile=certifi.where()) - return _ssl_context - - -def _create_http_client(options: ClientOptions | None = None) -> Client: - options = options or ClientOptions() - client = object.__new__(Client) - client._options = options - client._token = options.token - client._interceptors = list(options.interceptors or []) - client.http = httpx.AsyncClient( - base_url=httpx.URL(options.base_url) if options.base_url else "", - headers=options.headers, - timeout=options.timeout, - verify=_get_ssl_context(), - ) - client._update_event_hooks() - return client From 2d8f3df11adea75fbddf233a5b681ab3eecfb883 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Aug 2026 11:18:43 -0700 Subject: [PATCH 7/8] Updating changelog.md and minor formatting --- changelog.md | 4 ++++ .../hosting/core/app/proactive/conversation.py | 1 - .../hosting/core/telemetry/core/simple_span_wrapper.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index faf4f45d..66b84c2e 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ - Added support for Workload Identity - **Entra JWT Issuer Validation**: Added tenant ID cross-checking for Entra issuer claims and support for configuring issuer lists through environment variables (#515) - **AgentApplication Adaptive Card Routing**: Added `AgentApplication.adaptive_card` with decorator-based handlers for Adaptive Card `Action.Submit`, `Action.Execute`, and `Data.Query` dynamic search with verb and dataset matching selection. +- Distributed tracing across Proactive operations. ## New Models & APIs - **Regionalized UserTokenClient Support**: Added optional argument to `CloudAdapter` to configure Token Service endpoint used by `RestChannelServiceClientFactory` when creating `UserTokenClient` instances. @@ -13,6 +14,9 @@ ## Developer Experience - Building packages with `py.typed` files for improved typing support +- Support for linking with OpenTelemetry span creation throught the `SimpleSpanWrapper` constructor. + +--- # Microsoft 365 Agents SDK for Python - Release Notes v1.3.0 diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py index a65fdd3c..271bee5f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py @@ -13,7 +13,6 @@ if TYPE_CHECKING: from microsoft_agents.hosting.core.turn_context import TurnContext - from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter from .telemetry._utils import _deserialize_span_context, _dump_span_context diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py index 5ace0b55..e2f37163 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -19,7 +19,7 @@ def __init__( self, span_name: str, *, - link: Link | SpanContext | list[Link | SpanContext] | None = None + link: Link | SpanContext | list[Link | SpanContext] | None = None, ) -> None: super().__init__() self._span_name = span_name From 40ff9b8e14f94d0fbb1dec1e71d22cfcc680ac99 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 11 Aug 2026 11:25:52 -0700 Subject: [PATCH 8/8] Adding more test coverage --- dev/integration/tests/telemetry/__init__.py | 1 + .../telemetry/test_proactive_span_linking.py | 184 ++++++++++++++++++ .../tests/{ => telemetry}/test_telemetry.py | 6 +- .../app/proactive/test_conversation.py | 19 ++ .../telemetry/test_proactive_spans.py | 2 + 5 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 dev/integration/tests/telemetry/__init__.py create mode 100644 dev/integration/tests/telemetry/test_proactive_span_linking.py rename dev/integration/tests/{ => telemetry}/test_telemetry.py (96%) diff --git a/dev/integration/tests/telemetry/__init__.py b/dev/integration/tests/telemetry/__init__.py new file mode 100644 index 00000000..19f23458 --- /dev/null +++ b/dev/integration/tests/telemetry/__init__.py @@ -0,0 +1 @@ +"""Telemetry integration tests.""" diff --git a/dev/integration/tests/telemetry/test_proactive_span_linking.py b/dev/integration/tests/telemetry/test_proactive_span_linking.py new file mode 100644 index 00000000..d464dc9a --- /dev/null +++ b/dev/integration/tests/telemetry/test_proactive_span_linking.py @@ -0,0 +1,184 @@ +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + AgentAuthConfiguration, + ApplicationOptions, + Authorization, + MemoryStorage, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.app.proactive import ProactiveOptions +from microsoft_agents.hosting.core.app.proactive.telemetry import constants +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.testing import AgentEnvironment, AiohttpScenario + +from ..utils.telemetry_fixtures import ( # noqa: F401 + test_exporter, + test_telemetry, +) + + +class _FakeTokenProvider: + def __init__(self) -> None: + self._configuration = AgentAuthConfiguration() + + @property + def configuration(self) -> AgentAuthConfiguration: + return self._configuration + + async def get_access_token( + self, + resource_url: str, + scopes: list[str], + force_refresh: bool = False, + ) -> str: + return "test-access-token" + + +class _FakeConnections: + def __init__(self) -> None: + self._provider = _FakeTokenProvider() + + def get_connection(self, connection_name: str): + return self._provider + + def get_default_connection(self): + return self._provider + + def get_token_provider( + self, + claims_identity: ClaimsIdentity, + service_url: str, + ): + return self._provider + + def get_token_provider_from_activity( + self, + claims_identity: ClaimsIdentity, + activity: Activity, + ): + return self._provider + + def get_default_connection_configuration(self) -> AgentAuthConfiguration: + return self._provider.configuration + + +def _create_scenario() -> AiohttpScenario: + connections = _FakeConnections() + storage = MemoryStorage() + adapter = CloudAdapter(connection_manager=connections) + authorization = Authorization(storage, connections) + app = AgentApplication[TurnState]( + options=ApplicationOptions( + storage=storage, + adapter=adapter, + proactive=ProactiveOptions(), + ), + authorization=authorization, + ) + + @app.activity(ActivityTypes.message) + async def store_conversation(context: TurnContext, state: TurnState) -> None: + await app.proactive.store_conversation(context) + + environment = AgentEnvironment( + config={}, + agent_application=app, + authorization=authorization, + adapter=adapter, + storage=storage, + connections=connections, + ) + return AiohttpScenario(environment, use_jwt_middleware=False) + + +_SCENARIO = _create_scenario() + + +def _get_span(spans, name): + return next(span for span in spans if span.name == name) + + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_continue_conversation_links_to_stored_context( + test_exporter, + agent_client, + agent_application, + adapter, +): + activity = agent_client.template.create( + { + "type": ActivityTypes.message, + "id": "proactive-linking-activity", + } + ) + await agent_client.send(activity) + + async def continue_handler(context: TurnContext, state: TurnState) -> None: + pass + + await agent_application.proactive.continue_conversation( + adapter, + activity.conversation.id, + continue_handler, + ) + + spans = test_exporter.get_finished_spans() + store_span = _get_span(spans, constants.SPAN_STORE_CONVERSATION) + continuation_span = _get_span(spans, constants.SPAN_CONTINUE_CONVERSATION) + + assert len(continuation_span.links) == 1 + assert continuation_span.links[0].context == store_span.context + + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_overwriting_conversation_links_to_latest_store_span( + test_exporter, + agent_client, + agent_application, + adapter, +): + conversation_id = "proactive-overwrite-conversation" + first_activity = agent_client.template.create( + { + "type": ActivityTypes.message, + "id": "first-store-activity", + "conversation": {"id": conversation_id}, + } + ) + second_activity = agent_client.template.create( + { + "type": ActivityTypes.message, + "id": "second-store-activity", + "conversation": {"id": conversation_id}, + } + ) + + await agent_client.send(first_activity) + await agent_client.send(second_activity) + + async def continue_handler(context: TurnContext, state: TurnState) -> None: + pass + + await agent_application.proactive.continue_conversation( + adapter, + conversation_id, + continue_handler, + ) + + spans = test_exporter.get_finished_spans() + store_spans = [ + span for span in spans if span.name == constants.SPAN_STORE_CONVERSATION + ] + continuation_span = _get_span(spans, constants.SPAN_CONTINUE_CONVERSATION) + + assert len(store_spans) == 2 + assert len(continuation_span.links) == 1 + assert continuation_span.links[0].context == store_spans[-1].context + assert continuation_span.links[0].context != store_spans[0].context diff --git a/dev/integration/tests/test_telemetry.py b/dev/integration/tests/telemetry/test_telemetry.py similarity index 96% rename from dev/integration/tests/test_telemetry.py rename to dev/integration/tests/telemetry/test_telemetry.py index 4faf3ac5..8491c497 100644 --- a/dev/integration/tests/test_telemetry.py +++ b/dev/integration/tests/telemetry/test_telemetry.py @@ -10,14 +10,14 @@ from microsoft_agents.hosting.core.connector.telemetry import constants as connector_constants from microsoft_agents.hosting.core.storage.telemetry import constants as storage_constants -from .scenarios import load_scenario +from ..scenarios import load_scenario -from .utils.telemetry_fixtures import ( # unused imports are needed for fixtures +from ..utils.telemetry_fixtures import ( # unused imports are needed for fixtures test_telemetry, test_exporter, test_metric_reader, ) -from .utils.telemetry_utils import ( +from ..utils.telemetry_utils import ( sum_counter, sum_hist_count, find_metric diff --git a/tests/hosting_core/app/proactive/test_conversation.py b/tests/hosting_core/app/proactive/test_conversation.py index d6ca5adf..723de35b 100644 --- a/tests/hosting_core/app/proactive/test_conversation.py +++ b/tests/hosting_core/app/proactive/test_conversation.py @@ -4,6 +4,7 @@ """ import pytest +from opentelemetry.trace import SpanContext, TraceFlags, TraceState from unittest.mock import MagicMock from microsoft_agents.activity import ConversationAccount, ConversationReference @@ -203,3 +204,21 @@ def test_round_trip_preserves_service_url(self): json_data = original.store_item_to_json() restored = Conversation.from_json_to_store_item(json_data) assert restored.conversation_reference.service_url == "https://custom.service/" + + def test_round_trip_preserves_span_context(self): + span_context = SpanContext( + trace_id=0x4BF92F3577B34DA6A3CE929D0E0E4736, + span_id=0x00F067AA0BA902B7, + is_remote=True, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + trace_state=TraceState([("vendor", "value")]), + ) + original = Conversation( + claims={}, + conversation_reference=_make_reference("span-context-conv"), + ) + original._set_span_context(span_context) + + restored = Conversation.from_json_to_store_item(original.store_item_to_json()) + + assert restored._get_span_context() == span_context diff --git a/tests/hosting_core/telemetry/test_proactive_spans.py b/tests/hosting_core/telemetry/test_proactive_spans.py index cfecea88..adb772a8 100644 --- a/tests/hosting_core/telemetry/test_proactive_spans.py +++ b/tests/hosting_core/telemetry/test_proactive_spans.py @@ -198,6 +198,7 @@ def test_send_activity_creates_span(test_exporter): spans = test_exporter.get_finished_spans() assert len(spans) == 1 assert spans[0].name == constants.SPAN_SEND_ACTIVITY + assert len(spans[0].links) == 0 def test_send_activity_span_attributes(test_exporter): @@ -251,6 +252,7 @@ def test_continue_conversation_creates_span(test_exporter): spans = test_exporter.get_finished_spans() assert len(spans) == 1 assert spans[0].name == constants.SPAN_CONTINUE_CONVERSATION + assert len(spans[0].links) == 0 def test_continue_conversation_span_attributes(test_exporter):